178 Commits

Author SHA1 Message Date
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
Kavish Devar
43b98f7446 remove some local files 2025-01-30 03:51:32 +05:30
Kavish Devar
b6966f8c39 some progress on cross-device, and new dynamic island thingy! 2025-01-30 03:49:44 +05:30
Kavish Devar
8b57a97a54 Merge pull request #41 from devnoname120/remove-legacy-patchers
Remove legacy patchers
2025-01-28 14:53:23 +05:30
Kavish Devar
2d90898928 actually add the right video to the readme 2025-01-28 11:58:02 +05:30
Kavish Devar
5dc589c8ff add the right video to the readme 2025-01-28 11:53:39 +05:30
Kavish Devar
bbecb15f2e actually fix video in readme 2025-01-28 11:46:49 +05:30
Kavish Devar
3679c6acf6 fix video in readme 2025-01-28 11:38:36 +05:30
Kavish Devar
3f359e0315 bump version to android app 0.0.3 2025-01-28 11:28:28 +05:30
Kavish Devar
189f19ecb3 add transitions to readme 2025-01-28 11:23:32 +05:30
Kavish Devar
35da57f0a5 widgets! 2025-01-28 11:19:55 +05:30
Kavish Devar
ae188a72dc Merge pull request #42 from devnoname120/add-editorconfig-file
Add `.editorconfig` file
2025-01-27 19:23:58 +05:30
Kavish Devar
b607885fd8 Merge pull request #40 from devnoname120/patcher-module-remove-qti-support
Patcher module: abort for qti libraries
2025-01-27 19:22:29 +05:30
Kavish Devar
58b3ee47ed Merge pull request #39 from devnoname120/update-readme-with-on-device-patcher
README — Add instructions for on-device patcher
2025-01-27 19:22:13 +05:30
Kavish Devar
75fd974473 Merge pull request #38 from devnoname120/remove-curl-remains
Remove unused cURL that got reintroduced by error
2025-01-27 19:21:56 +05:30
Paul
b71471e1d5 Add .editorconfig file 2025-01-27 00:41:05 +01:00
Paul
40547919ce Remove legacy patchers 2025-01-27 00:38:16 +01:00
Paul
a5c65a324f Patcher module: abort for qti libraries 2025-01-27 00:28:35 +01:00
Paul
2f9fbdc70e README — Add instructions for on-device patcher 2025-01-27 00:12:19 +01:00
Paul
650a8d7e66 Remove unused cURL that got reintroduced by error 2025-01-26 23:57:51 +01:00
Kavish Devar
f67e5defcf add iOS style battery widget 2025-01-26 19:14:53 +05:30
Kavish Devar
66d7adf22c uhhh, idk how i added this file to git 2025-01-26 16:26:30 +05:30
Kavish Devar
c48c6238ed Merge pull request #36 from tim-gromeyer/fix-ui-text
Fix ui text
2025-01-26 02:01:50 +05:30
Kavish Devar
8d67f54e40 Merge pull request #34 from devnoname120/magisk-module-autopatcher
Add on-device patcher module for Magisk/KernelSU
2025-01-26 01:38:47 +05:30
Paul
ab67d7dc23 Add auto-update metadata to the patcher module 2025-01-25 14:58:32 +01:00
Paul
e1dc2c8925 Create on-device Bluetooth patcher module (arm64)
Tested and working on Android 15 QPR2 (library: `/apex/com.android.btservices/lib64/libbluetooth_jni.so`)
2025-01-25 14:58:32 +01:00
Paul
93838d2d10 Add static arm64 busybox 1.36.1 2025-01-25 14:57:00 +01:00
Paul
29acd203ae Add custom radare2 5.9.9 build for Android
https://github.com/devnoname120/radare2
2025-01-25 14:57:00 +01:00
Paul
e81ae32b1f Get rid of cURL 2025-01-25 14:57:00 +01:00
Kavish Devar
d01fe3938d move files across computers - again! 2025-01-25 13:55:55 +05:30
Kavish Devar
5d364a662c move files across computers 2025-01-25 03:12:23 +05:30
Kavish Devar
a6d7bd704a move files across computers 2025-01-25 00:00:43 +05:30
Tim Gromeyer
2e87a3d66f Fix ui text 2025-01-23 10:10:12 +01:00
Kavish Devar
938278b0b5 try to add cross device stuff 2025-01-21 21:29:16 +05:30
Kavish Devar
3a3074f592 work out the accessiblity settings packets 2025-01-20 20:18:24 +05:30
Kavish Devar
7a06f3055c try to add some cross-device stuff 2025-01-20 04:13:18 +05:30
Kavish Devar
7cac2b037f commit before letting the llm edit the code 2025-01-20 01:43:33 +05:30
Kavish Devar
4b3e1130e8 smth works :D 2025-01-17 04:28:58 +05:30
Kavish Devar
c9b04b24c0 fix typo 2025-01-17 04:14:40 +05:30
Kavish Devar
e1c6677753 add ui for accessibility settings 2025-01-17 03:20:34 +05:30
Kavish Devar
45d2cc302e try making an app; commiting for highseas 2025-01-17 03:20:16 +05:30
Kavish Devar
7bd17635e5 add even more libs for curl 2025-01-15 07:55:11 +05:30
Kavish Devar
39f0ae7106 add libs for curl for CI 2025-01-14 02:13:55 +05:30
Kavish Devar
dfc6dd611b remove issue templates 2025-01-14 02:12:22 +05:30
Kavish Devar
888f1b3616 add better logging to the root module installer 2025-01-14 01:49:03 +05:30
Kavish Devar
9a4d561f23 update link to module in readme 2025-01-14 01:43:58 +05:30
Kavish Devar
ba22d7c2a7 build root module in ci 2025-01-14 01:41:59 +05:30
Kavish Devar
d77142c9a3 remove libcurl-android folder after install 2025-01-14 01:28:16 +05:30
Kavish Devar
f265184abb clean up patching server 2025-01-14 01:23:23 +05:30
Kavish Devar
122e3d6c42 forgot to add zip 2025-01-14 01:01:17 +05:30
Kavish Devar
f6d96b6a09 finally done with the module, tested on my own device 2025-01-14 01:00:22 +05:30
Kavish Devar
d63a2ac632 updated AAP Definitions 2025-01-14 00:54:31 +05:30
Kavish Devar
6564314ce0 hopefully fixed 2025-01-13 15:27:20 +05:30
Kavish Devar
b8054b2189 try fixing the zip again 2025-01-13 09:57:32 +05:30
Kavish Devar
676c1329f3 update link to module in readme 2025-01-13 02:27:07 +05:30
Kavish Devar
6975d519cc forgot to add libraries for curl to gitignore 2025-01-13 02:21:28 +05:30
Kavish Devar
c3e5f9e9c0 why do i forget to fetch and pull everytime i use the web editor :( 2025-01-13 02:19:43 +05:30
Kavish Devar
75d348bc95 move back to server-based patching 2025-01-13 02:19:03 +05:30
Kavish Devar
c61b07d721 try radare2 for on-device patching 2025-01-13 01:46:51 +05:30
Kavish Devar
c93b5e2cdf update workaround instructions and feature list 2025-01-12 09:10:11 +05:30
Kavish Devar
2550aec66d add root module 2025-01-12 09:05:13 +05:30
Kavish Devar
4df1f6f1c5 fix broken symlinks in modules 2025-01-11 01:47:25 +05:30
Kavish Devar
ac2fcd384d fix module log message 2025-01-11 01:44:41 +05:30
Kavish Devar
6c6c6d9390 hopefully this works 2025-01-11 01:38:33 +05:30
Kavish Devar
6c43a69542 llms and depracations pt.2 2025-01-11 01:33:06 +05:30
Kavish Devar
c2ecc93d98 forgot ctrl+z existed 2025-01-11 01:28:36 +05:30
Kavish Devar
a51ac11b29 llms :( 2025-01-11 01:19:49 +05:30
Kavish Devar
57b377d436 llms and depracated stuff 2025-01-11 01:13:49 +05:30
Kavish Devar
a72cbf129f im soo stupid ;-; 2025-01-11 01:10:59 +05:30
Kavish Devar
d197a05a07 try to automate the patching process even further 2025-01-11 00:57:36 +05:30
Kavish Devar
ea403728e2 try to fix stupidity, again 2025-01-11 00:40:01 +05:30
Kavish Devar
383d8b2491 try to fix stupidity 2025-01-11 00:36:06 +05:30
Kavish Devar
f367392824 smth 2025-01-11 00:34:15 +05:30
Kavish Devar
f6ac82357f fix a few things in the patcher server 2025-01-11 00:16:16 +05:30
Kavish Devar
3461179389 add source code link in patcher web 2025-01-11 00:04:35 +05:30
Kavish Devar
a2019d7421 add api for patching 2025-01-10 23:54:06 +05:30
Kavish Devar
5c68e4fcec add support for qualcomm libraries and a README 2025-01-10 23:37:07 +05:30
Kavish Devar
d7b6353bcf add manual patching script and flask server 2025-01-10 21:32:57 +05:30
Kavish Devar
4ef74712b2 remove hook file 2025-01-10 10:59:48 +05:30
Kavish Devar
4c3d3e7286 add screenshot for customizations 2025-01-09 03:36:52 +05:30
Kavish Devar
19a286389b oops: forgot to change the starting screen back to normal 2025-01-09 03:27:57 +05:30
Kavish Devar
f5cc47b53c add an app settings page for customizing functions and un-hardcode strings 2025-01-09 03:25:32 +05:30
Kavish Devar
fc0475e2c0 add commit list to nightly release 2025-01-08 00:04:29 +05:30
Kavish Devar
5362108306 remove something that wasn't supposed to be herr 2025-01-07 23:58:27 +05:30
Kavish Devar
d3a7727fd3 Merge pull request #25 from kavishdevar/release-nightly
something happened
2025-01-07 23:24:56 +05:30
Kavish Devar
4426fe1f4f Merge branch 'main' into release-nightly 2025-01-07 23:23:38 +05:30
Kavish Devar
9838dbd921 fix for pre-tiramisu android versions 2025-01-07 23:22:41 +05:30
Kavish Devar
fda343ca39 fix for pre-tiramisu android versions 2025-01-07 23:22:16 +05:30
Kavish Devar
278dc44796 show airpods name in notification 2025-01-07 22:45:22 +05:30
Kavish Devar
c6863a8d2c Merge pull request #24 from kavishdevar/last-saved-battery
Show last saved battery in notification
2025-01-07 10:23:04 +05:30
Kavish Devar
039a43d15d Untitled
Update `AirPodsService.kt` and `BatteryWidget.kt` to retain and display the last received battery levels when the case or bud gets disconnected.

* **AirPodsService.kt**
  - Comment out the condition that sets battery levels to an empty string if the status is `BatteryStatus.DISCONNECTED`.

* **BatteryWidget.kt**
  - Comment out the condition that sets battery levels to an empty string if the status is `BatteryStatus.DISCONNECTED`.
2025-01-07 10:19:20 +05:30
Kavish Devar
5995c174e1 save name name whn not renamed 2025-01-07 01:34:10 +05:30
Kavish Devar
fad2681869 i'm stupid. fix device name being null when not renamed 2025-01-07 01:32:05 +05:30
Kavish Devar
ed726922af only show battery level if last sent by airpods and not set it to 0 2025-01-07 01:30:01 +05:30
Kavish Devar
5d9f91af6e merge #21 from devnoname120/improve-notification
improve notifications
2025-01-07 01:23:05 +05:30
Kavish Devar
96c4966bc2 Merge branch 'main' into improve-notification 2025-01-07 01:22:00 +05:30
Kavish Devar
5dbfe69ed4 center align title in debugscreen 2025-01-07 01:20:31 +05:30
Kavish Devar
fb4611677e try to add animations 2025-01-07 01:07:24 +05:30
Kavish Devar
af59b70537 do not show "off" option when off listening mode is disabled 2025-01-07 01:07:09 +05:30
Paul
bd473ee589 improve notification to hide L/R/C when missing 2025-01-06 20:17:46 +01:00
Paul
baacc9f1e2 fix service killed by Android battery saver
having an always-on/ongoing notification is required in order for Android not to kill it
2025-01-06 20:16:24 +01:00
Paul
416ae1e974 change service notif to AirPods not connected 2025-01-06 20:15:07 +01:00
Kavish Devar
2c2552a57e show last battery status when a pod or case is disconnected 2025-01-07 00:35:44 +05:30
Kavish Devar
7ed8f9b09c fix: when both airpods are not worn, the settings screen would say not connected 2025-01-06 22:22:15 +05:30
Kavish Devar
140ef0869b improve notification when connected 2025-01-06 22:04:39 +05:30
Kavish Devar
d4e45b221a remove spacing from project name 2025-01-06 18:55:20 +05:30
Kavish Devar
3b1f66370a Update Linux project status, and add QPR1 instructions 2025-01-06 16:05:15 +05:30
Kavish Devar
130b83c91a fetch service UUIDs before checking UUIDs of a bluetooth device when starting service 2025-01-06 14:26:13 +05:30
Kavish Devar
c941d0d320 make conversational awareness volume change smoother and improve media control by ear detection 2025-01-04 18:21:24 +05:30
Kavish Devar
bf1ebd01e4 fix bug where it would occasionally crash when connecting 2025-01-03 21:41:46 +05:30
Kavish Devar
2b577d7a49 fix release link for workaround files 2025-01-03 02:07:54 +05:30
Kavish Devar
66eaa985c8 improve media control logic 2025-01-03 02:05:20 +05:30
Kavish Devar
9fea483d51 bug fix :) 2024-12-30 23:29:45 +05:30
Kavish Devar
55aca982a1 change back license from GPL to AGPL 2024-12-30 20:40:40 +05:30
Kavish Devar
26303192e5 added battery widget screenshot 2024-12-24 03:49:23 +05:30
Kavish Devar
4ad653f064 added battery widget 2024-12-24 03:45:01 +05:30
Kavish Devar
8792e992da small ui changes 2024-12-16 19:03:29 +05:30
Kavish Devar
430e1d6c41 use relative values for lowering volume (conversational awarness) instead of absolute 2024-12-16 19:03:15 +05:30
Kavish Devar
7d3b80292b fix ci?? 2024-12-16 17:22:05 +05:30
Kavish Devar
f5032c5e8e fix ci?? 2024-12-16 17:17:08 +05:30
Kavish Devar
22700b897f maybe fix signing? 2024-12-16 17:05:32 +05:30
Kavish Devar
0f563a60d1 change from text input on workflow dispatch to checkbox 2024-12-16 16:58:48 +05:30
Kavish Devar
6774ca1bb5 add debug keystore for ci 2024-12-16 16:48:25 +05:30
Kavish Devar
5f8f1d2041 fix pathspec for uploading artifact 2024-12-16 16:39:03 +05:30
Kavish Devar
1155a7103d add ci 2024-12-16 16:32:14 +05:30
Kavish Devar
66c550bae5 add ci 2024-12-16 16:29:54 +05:30
Kavish Devar
34ace1fc6e try adding widget; add previews to each composable 2024-12-16 16:13:58 +05:30
Kavish Devar
ff6c72ffa6 fix xda link in readme 2024-12-13 01:43:55 +05:30
Kavish Devar
1f3cd50f11 add xdaforums thread link to readme 2024-12-13 01:42:36 +05:30
Kavish Devar
809818f60d split compose functions, and organize stuff 2024-12-13 01:41:37 +05:30
Kavish Devar
7b03a7e9f0 android: add long press settings to cycle between anc modes 2024-12-11 23:05:22 +05:30
Kavish Devar
95664ed4be smth 2024-12-10 12:51:33 +05:30
Kavish Devar
214c7ac1c9 Merge branch 'main' of github.com:kavishdevar/aln 2024-12-10 12:49:52 +05:30
Kavish Devar
255edc5b08 move files across computers 2024-12-10 12:49:16 +05:30
Kavish Devar
5dfd962ffa update AA(C)P status for tone volume and ANC with single airpod 2024-12-09 00:14:57 +05:30
Kavish Devar
f5c0a02291 move screenshots to the top 2024-12-09 00:10:53 +05:30
Kavish Devar
4d8841205b move packet definitions block from top to bottom in readme 2024-12-06 23:02:31 +05:30
Kavish Devar
1afc61513f change workaround's note from important to caution 2024-12-06 23:01:10 +05:30
Kavish Devar
3b4f887300 update readme 2024-12-06 22:57:51 +05:30
Kavish Devar
be02ab1830 bump version 2024-12-06 21:32:24 +05:30
Kavish Devar
405584f8eb add more screenshots for the app 2024-12-06 21:06:46 +05:30
Kavish Devar
b1249f27f5 fix QSTile 2024-12-06 21:06:36 +05:30
Kavish Devar
ebdb1609f3 add proper colored popups like ios 2024-12-06 19:57:21 +05:30
Kavish Devar
db8458be59 try to fix service, again 2024-12-06 14:09:28 +05:30
Kavish Devar
4374a81915 move to haze for blurring 2024-12-06 13:06:00 +05:30
Kavish Devar
2679205dc3 fix background connections, add manual force connection button 2024-12-06 11:56:07 +05:30
Kavish Devar
0c1f9464ad add persistent notification for battery; bug fixes 2024-12-05 10:02:19 +05:30
Kavish Devar
7a246b3800 minor readme change 2024-12-03 22:10:15 +05:30
Kavish Devar
9fa8046886 Merge pull request #9 from SriviharReddy/new-branch
Readme Update
2024-12-03 22:08:27 +05:30
Victor Reeds
2c64cf7cf7 Fixed some spelling and grammar errors 2 2024-12-03 14:00:36 +05:30
Victor Reeds
c6ab7038cd Fixed some spelling and grammar errors 2024-12-03 13:59:12 +05:30
Victor Reeds
8917753830 Updated readme for better clarity 2024-12-03 13:25:11 +05:30
Kavish Devar
3fe268eb15 head gestures, maybe?? 2024-12-03 03:15:29 +05:30
Kavish Devar
f12cf0c16f move head-tracking files accross computers 2024-12-03 00:37:53 +05:30
Kavish Devar
c6af6a147f try to fix conversational awareness volume restore 2024-12-02 23:35:27 +05:30
Kavish Devar
71dffd1415 idk what i'm doing 2024-12-01 22:25:18 +05:30
Kavish Devar
4a1d7df82d add battery to the popup 2024-11-30 08:35:02 +05:30
Kavish Devar
3e0de6f011 add popup window when connected; automatically open socket in background 2024-11-29 23:45:32 +05:30
Kavish Devar
58de49d1b1 try to add automatic device connection detection; add "Off Listening Mode" toggle 2024-11-29 23:45:27 +05:30
Kavish Devar
c360c21305 Update README.md 2024-11-21 07:22:06 +05:30
Kavish Devar
48e7c91282 Update README.md 2024-11-16 22:37:39 +05:30
Kavish Devar
61e4a4168c fix tray noise control 2024-11-05 09:01:02 +05:30
Kavish Devar
b6a35756d0 Create CONTRIBUTING.md 2024-11-05 01:03:30 +05:30
Kavish Devar
2c68c1cf18 change tagline 2024-11-05 00:50:37 +05:30
Kavish Devar
6424a51e8a Create CODE_OF_CONDUCT.md 2024-11-05 00:48:05 +05:30
Kavish Devar
bef3cd9774 android: add Loud Sound Reduction and personalized volume toggle 2024-10-24 08:14:47 +05:30
Kavish Devar
f4cd02831c Check renaming airpods in Android off in README 2024-10-20 17:05:47 +05:30
Kavish Devar
745040be2b android: add QS Tile to change Noise Control Mode 2024-10-19 19:48:12 +05:30
Kavish Devar
a8de72f190 Add workaround instructions for android in README 2024-10-18 23:12:27 +05:30
196 changed files with 13476 additions and 2311 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.{md,apib}]
indent_size = 4
# In Markdown a trailing double space is interpreted as <br>
trim_trailing_whitespace = false
max_line_length = off
[*.{py,java,r,R,kt,xml,kts}]
indent_size = 4

View File

@@ -1,31 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- Distro: [e.g. Ubuntu, KDE Neon, Arch]
- Version [e.g. 22.04]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

74
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: Build APK and root module (and create nightly release)
on:
push:
branches:
- '*'
workflow_dispatch:
inputs:
release:
description: 'Create a nightly release'
required: true
type: boolean
default: false
workflow_call:
jobs:
build-debug-apk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 21
- uses: gradle/actions/setup-gradle@v4
- name: Build debug APK
run: ./gradlew assembleDebug
working-directory: android
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Debug APK
path: android/app/build/outputs/apk/**/*.apk
nightly-release:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true'
needs: build-debug-apk
permissions:
contents: write
steps:
- 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
- name: Rename .apk file
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
- name: Decode keystore file
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
- name: Install apksigner
run: sudo apt-get update && sudo apt-get install -y apksigner
- name: Sign APK
run: |
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
- name: Verify APK
run: apksigner verify "./$APK_NAME"
- name: Fetch the latest non-nightly release tag
id: fetch-tag
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
- name: Retrieve commits since the last release
id: get-commits
run: |
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
echo "::set-output name=commits::${COMMITS}"
- name: Zip root-module directory
run: zip -r -0 ../btl2capfix.zip * --verbose
working-directory: root-module
- 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

8
.gitignore vendored
View File

@@ -1,5 +1,11 @@
wak.toml
log.txt
btl2capfix.zip
.vscode
testing.py
.DS_Store
CMakeLists.txt.user*
# Android Template
# Gradle files
@@ -44,7 +50,7 @@ __pycache__/
*$py.class
# C extensions
*.so
# *.so
# Distribution / packaging
.Python

View File

@@ -28,7 +28,7 @@ It also enables the Adaptive Transparency feature. (We can set Adaptive Transpar
# Requesting notifications
This packet is necessary to receive notifications from the AirPods like ear detection, noise control mode, conversation awareness, battery status, etc.
This packet is necessary to receive notifications from the AirPods like ear detection, noise control mode, conversational awareness, battery status, etc.
*Captured using PacketLogger on an Intel Mac running macOS Sequoia 15.0.1*
```plaintext
@@ -211,34 +211,72 @@ The level can be any value between 0 and 100, 0 to allow maximum noise (i.e. min
*I find it quite funny how I have greater control over the noise control on the AirPods on non-Apple devices than on Apple devices, becuase on Apple Devices, there are just 3 options More Noise (0), Midway through (50), and Less Noise (100), but here I can set any value between 0 and 100.*
# To-Do List
## Accessiblity Settings
- [x] Receive Battery Information
- [x] Set/Receive ANC Modes
- [x] Set Adaptive Audio Noise settings
- [x] Receive In-Ear detection Status
- [ ] Personalized Volume (idk how this works, if it received data from icloud, or is purely from airpods)
- [x] Conversational Awareness
- [x] Ear Detection
- [ ] Head Gestures
- [ ] Siri (Voice assistant on long stem press)
- [ ] Hold and Press configuration (this is really weird, mac sends different packets based on what the current status is, instead of a fixed packet for what the current button state is, like common there's only so many to map out numbers to states)
- [ ] Head Tracking (i really want this, could be easy, could be difficult, i'll never know because i don't have a device with apple silicon 😭)
- [x] Case Charging Sounds
- [x] Rename AirPods
- [ ] Mute Unmute Calls
- Accessibilty
- [ ] Press Speed
- [ ] Press and hold duration
- [ ] Noise Cancellation with one AirPod
- [ ] Tone Volume
- [ ] Toggle Volume Control on Swipe (APP only, i believe)
- [ ] Volume Swipe (Normal/Longer/Longest)
- [ ] Headphone accomodation (I literally can't tell the difference between any samples played, lol, also, idk if this is something that the mac does)
- [ ] Audio Tuning (idk if this is also smth that mac does)
- [ ] Customize Transparency Mode (This is gonna take some while to parse, it is 103 bytes :(... probably all the 4 sliders and 1 switch under this is sent as a whole)
## Headphone Accomodation
```
04 00 04 00 53 00 84 00 02 02 [Phone] [Media]
[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8]
duplicated thrice for some reason
```
| Data | Type | Value range |
|---------------------|---------------|-----------------------------|
| Phone | Decimal | 1 (Enabled) or 2 (Disabled) |
| Media | Decimal | 1 (Enabled) or 2 (Disabled) |
| EQ | Little Endian | 0 to 100 |
## Customize Transparency mode
```
52 18 00
For left bud
[Enabled]
[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8]
[Amplification]
[Tone]
[Conversation Boost]
[Ambient Noise Reduction]
00 0080 3F
<same for the right bud>
```
<!-- demo packet
52 18 00 00 00 00 00 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 00 00 80 3f 00 00 80 3f 00 00 80 3f 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 00 00 80 3f 00 00 80 3f 00 00 80 3f
-->
<!--
5218 0000 0080 3F62 10DA 413D 0AF0 4160
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
1846 4293 1846 4206 9476 BF00 576E BB00
0080 3F00 0080 3F62 10DA 413D 0AF0 4160
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
1846 4293 1846 4200 0080 BF00 576E BB00
0080 3F00 0080 3F
-->
<!--
5218 0000 0000 0062 10DA 413D 0AF0 4160
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
1846 4293 1846 4206 9476 BF00 576E BB00
0080 3F00 0080 3F62 10DA 413D 0AF0 4160
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
1846 4293 1846 4200 0080 BF00 576E BB00
0080 3F00 0080 3F
-->
All values are formatted as Little Endian from float values.
| Data | Type | Value range |
|---------------------|---------------|-------------|
| Enabled | Little Endian | 0 or 1 |
| EQ | Little Endian | 0 to 100 |
| Amplification | Little Endian | -1 to 1 |
| Tone | Little Endian | -1 to 1 |
| Conversation Boost | Little Endian | 0 or 1 |
> [!IMPORTANT]
> Also send the [Headphone Accomodation](#headphone-accomodation) after this.
# Miscellaneous/Unknown
## Configure Stem Long Press
@@ -310,10 +348,14 @@ 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
## Request something (Probably Head Positions)
```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
<!-- 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
@@ -327,3 +369,20 @@ Example packet
```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
```
# LICENSE
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/>.

4
CHANGELOG.md Normal file
View File

@@ -0,0 +1,4 @@
## 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)_

128
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
report@kavishdevar.me.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

70
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,70 @@
# Welcome to AirPods Like Normal 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.
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful.
This guide provides an overview of the contribution workflow, from opening an issue to creating and reviewing a pull request (PR).
## New contributor guide
To get an overview of the project, read the [README](./README.md). Here are some resources to help you get started with open-source contributions:
- [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
- [Set up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
- [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow)
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
## Getting started
To navigate our codebase with confidence, see the [README](./README.md) for setup instructions and usage details. We accept various types of contributions, which dont always require writing code (like translations).
To develop for the Android App, Android Studio is the preferred IDE. And you can use any IDE for the linux program, it is just python!
### Issues
#### Create a new issue
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/aln/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.
### Make Changes
#### Make changes locally
1. Fork the repository and clone it to your local environment.
```
git clone https://github.com/kavishdevar/aln.git
cd AirPods-Like-Normal
```
2. Create a working branch to start your changes.
```
git checkout -b your-feature-branch
```
3. Make your changes, following the existing style and structure.
4. Test your changes to ensure they work as expected and do not introduce new issues.
### Commit your changes
Commit your changes with a descriptive message.
### Pull Request
When your changes are ready, create a pull request (PR):
- Fill out the PR template to help reviewers understand your changes.
- If your PR is related to an issue, dont forget to [link your PR to it](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Enable the checkbox to allow maintainers to edit your PR, so any required changes can be merged easily.
Once your PR is open, a team member will review it. They may ask questions or request additional information.
- If changes are requested, apply them in your fork and commit them to the PR branch.
- Mark conversations as resolved as you apply feedback.
- For merge conflicts, follow this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to resolve them.
### Your PR is merged!
Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal.

161
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -631,44 +629,33 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
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, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
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 General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

110
README.md
View File

@@ -1,56 +1,112 @@
# ALN - AirPods like Normal
*Bringing Apple-only features to Linux and Android for seamless AirPods functionality!*
### Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md)
# ALN - AirPodsLikeNormal
*Bringing AirPods' Apple-exclusive features on linux and android!*
## Currently supported device(s)
## [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
## Implemented Features
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).
| Feature | Linux | Android |
| --- | --- | --- |
| Ear Detection | ✅ | ✅ |
| Conversational Awareness | ✅ | ✅ |
| Setting Noise Control | ✅ | ✅ |
| Battery Level | ✅ | ✅ |
| Rename AirPods | ✅ | ❌ |
| Adjust Adaptive Audio | ❌ | ✅ |
## Features
Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list.
## Linux
## CrossDevice Stuff
> [!IMPORTANT]
> This feature is still in development and might not work as expected. No support is provided for this feature.
### Features
- **Battery Status**: Get battery status on any device when you connect your AirPods to one of them.
- **Control AirPods**: Control your AirPods from either of your device when you connect to one, like changing the noise control mode, toggling conversational awareness, and more.
- **Automatic Device Switching**: Automatically switch between your Linux and Android device, like when you receive a call, start playing music on Android while you're connected to Linux, and more!
Check out the demo below!
https://raw.githubusercontent.com/kavishdevar/aln/main/android/imgs/cd-demo-2.mp4
## Linux — Deprecated, rewrite WIP!
> No support will be provided for the old version of the Linux app. The new version is still in development and might not work as expected. No support is provided for the new version either.
Check out the README file in [linux](/linux) folder for more info.
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.
![Tray Battery App](/linux/imgs/tray-icon-hover.png)
![Tray Noise Control Mode Menu](/linux/imgs/tray-icon-menu.png)
![Tray Battery App](/linux.old/imgs/tray-icon-hover.png)
![Tray Noise Control Mode Menu](/linux.old/imgs/tray-icon-menu.png)
## Android
> Currently, there's a [bug on android](https://issuetracker.google.com/issues/371713238) that prevents this from working (psst, go upvote!)
> Can I use aln without root?
But once that's fixed, or you have fixed the issue using root, download the APK, and you're off!
**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.**
I don't know how to write READMEs for android apps, because they're just that, apps. So, here are two screenshots of the app:
### Screenshots
![AirPods Settings](/android/imgs/settings.png)
![Debugging View](/android/imgs/debug.png)
Check out the new animations and transitions in the app!
> Quick Tile to toggle Conversational Awareness and to switch Noise Control modes, and Battery Widget (App and AndroidSystemIntelligence)/Notification coming soon!!
https://github.com/user-attachments/assets/470ffc9c-3e52-4dcf-818d-0d1f60986c2e
| | | |
|-------------------|-------------------|-------------------|
| ![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) |
| ![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).
> [!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.
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.
The module to install is available in the releases section under the name `btl2capfix.zip`.
### Android features
#### 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!
#### Noise Control Modes
- 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.
> [!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!
#### Conversational Awareness
Automatically lowers audio volume and enhances voices when you start speaking, making it easier to engage in conversations without removing your AirPods.
#### Automatic Ear Detection
Recognizes when the AirPods are in your ears to automatically play or pause audio and adjust functionality accordingly.
## Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md)
# License
AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
AirPodsLikeNormal (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 General Public License as published by
the Free Software Foundation, version 3 of the License.
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 General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
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/>.

View File

@@ -1,9 +0,0 @@
# Android App for ALN
> Sorry, I don't know how to write READMEs for android apps, because they're just that, apps. So, here are 2 screenshots of the app:
## Settings Screen
![Settings Screen](/android/imgs/settings.png)
## Debug Screen
![Debug Screen](/android/imgs/debug.png)

View File

@@ -7,14 +7,14 @@ plugins {
android {
namespace = "me.kavishdevar.aln"
compileSdk = 34
compileSdk = 35
defaultConfig {
applicationId = "me.kavishdevar.aln"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 3
versionName = "0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -37,6 +37,7 @@ android {
}
buildFeatures {
compose = true
viewBinding = true
}
}
@@ -53,6 +54,9 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.annotations)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.constraintlayout)
implementation(libs.haze)
implementation(libs.haze.materials)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -7,53 +7,106 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED"
<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.BLUETOOTH_ADMIN"
<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"
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ALN"
android:enableOnBackInvokedCallback="true"
tools:targetApi="31"
tools:ignore="UnusedAttribute">
tools:ignore="UnusedAttribute"
tools:targetApi="31">
<receiver
android:name=".widgets.NoiseControlWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/noise_control_widget_info" />
</receiver>
<receiver
android:name=".widgets.BatteryWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/battery_widget_info" />
</receiver>
<activity
android:name=".CustomDevice"
android:exported="true"
android:label="@string/title_activity_custom_device"
android:theme="@style/Theme.ALN">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ALN">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AirPodsService"
android:name=".services.AirPodsService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="connectedDevice"
android:permission="android.permission.BLUETOOTH_CONNECT" />
<service
android:name=".services.AirPodsQSService"
android:exported="true"
android:icon="@drawable/airpods"
android:label="ANC Mode"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- <receiver android:name=".StartupReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.bluetooth.device.action.ACL_CONNECTED" />-->
<!-- <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.BOND_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.NAME_CHANGED" />-->
<!-- <action android:name="android.intent.action.BOOT_COMPLETED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.STATE_CHANGED" />-->
<!-- </intent-filter>-->
<!-- </receiver>-->
<receiver
android:name=".receivers.BootReceiver"
android:enabled="true"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
</manifest>

View File

@@ -1,476 +0,0 @@
package me.kavishdevar.aln
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothSocket
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.ParcelUuid
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass
private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV"
private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1
private const val APPLE = 0x004C
const val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
const val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL"
private const val PACKAGE_ASI = "com.google.android.settings.intelligence"
private const val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
//private const val COMPANION_TYPE_NONE = "COMPANION_NONE"
//const val VENDOR_RESULT_CODE_COMMAND_ANDROID = "+ANDROID"
class AirPodsService : Service() {
inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService
}
override fun onBind(intent: Intent?): IBinder {
return LocalBinder()
}
var isRunning: Boolean = false
private var socket: BluetoothSocket? = null
fun sendPacket(packet: String) {
val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
socket?.outputStream?.write(fromHex.toByteArray())
socket?.outputStream?.flush()
}
fun setANCMode(mode: Int) {
when (mode) {
1 -> {
socket?.outputStream?.write(Enums.NOISE_CANCELLATION_OFF.value)
}
2 -> {
socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ON.value)
}
3 -> {
socket?.outputStream?.write(Enums.NOISE_CANCELLATION_TRANSPARENCY.value)
}
4 -> {
socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
}
}
socket?.outputStream?.flush()
}
fun setCAEnabled(enabled: Boolean) {
socket?.outputStream?.write(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
}
fun setAdaptiveStrength(strength: Int) {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00)
socket?.outputStream?.write(bytes)
socket?.outputStream?.flush()
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
val ancNotification = AirPodsNotifications.ANC()
val batteryNotification = AirPodsNotifications.BatteryNotification()
val conversationAwarenessNotification = AirPodsNotifications.ConversationalAwarenessNotification()
var earDetectionEnabled = true
fun setCaseChargingSounds(enabled: Boolean) {
val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01)
socket?.outputStream?.write(bytes)
socket?.outputStream?.flush()
}
fun setEarDetection(enabled: Boolean) {
earDetectionEnabled = enabled
}
fun getBattery(): List<Battery> {
return batteryNotification.getBattery()
}
fun getANC(): Int {
return ancNotification.status
}
//
// private fun buildBatteryText(battery: List<Battery>): String {
// val left = battery[0]
// val right = battery[1]
// val case = battery[2]
//
// return "Left: ${left.level}% ${left.getStatusName()}, Right: ${right.level}% ${right.getStatusName()}, Case: ${case.level}% ${case.getStatusName()}"
// }
private fun createNotification(): Notification {
val channelId = "battery"
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.pro_2_buds)
.setContentTitle("AirPods Connected")
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
val channel =
NotificationChannel(channelId, "Battery Notification", NotificationManager.IMPORTANCE_LOW)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
return notificationBuilder.build()
}
fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}
fun connectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}
fun updatePodsStatus(device: BluetoothDevice, batteryList: List<Battery>) {
var batteryUnified = 0
var batteryUnifiedArg = 0
// Handle each Battery object from batteryList
// batteryList.forEach { battery ->
// when (battery.getComponentName()) {
// "LEFT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 10, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 13, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "RIGHT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 11, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 14, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "CASE" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 12, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 15, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// }
// }
// Sending broadcast for battery update
broadcastVendorSpecificEventIntent(
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV,
APPLE,
BluetoothHeadset.AT_CMD_TYPE_SET,
batteryUnified,
batteryUnifiedArg,
device
)
}
@Suppress("SameParameterValue")
@SuppressLint("MissingPermission")
private fun broadcastVendorSpecificEventIntent(
command: String,
companyId: Int,
commandType: Int,
batteryUnified: Int,
batteryUnifiedArg: Int,
device: BluetoothDevice
) {
val arguments = arrayOf(
1, // Number of key(IndicatorType)/value pairs
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level
batteryUnifiedArg // Battery Level
)
val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply {
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, command)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, commandType)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments)
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(BluetoothDevice.EXTRA_NAME, device.name)
addCategory(BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY + "." + companyId.toString())
}
sendBroadcast(intent)
val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply {
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(EXTRA_BATTERY_LEVEL, batteryUnified)
}
sendBroadcast(batteryIntent)
val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).setPackage(PACKAGE_ASI).apply {
putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent)
}
sendBroadcast(statusIntent)
}
fun setName(name: String) {
val nameBytes = name.toByteArray()
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01,
nameBytes.size.toByte(), 0x00) + nameBytes
socket?.outputStream?.write(bytes)
socket?.outputStream?.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPodsService", "setName: $name, sent packet: $hex")
}
@SuppressLint("MissingPermission", "InlinedApi")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = createNotification()
startForeground(1, notification)
if (isRunning) {
return START_STICKY
}
isRunning = true
@Suppress("DEPRECATION") val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) intent?.getParcelableExtra("device", BluetoothDevice::class.java) else intent?.getParcelableExtra("device")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
socket = HiddenApiBypass.newInstance(BluetoothSocket::class.java, 3, true, true, device, 0x1001, uuid) as BluetoothSocket?
try {
socket?.connect()
socket?.let { it ->
it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED))
it.outputStream.flush()
CoroutineScope(Dispatchers.IO).launch {
while (socket?.isConnected == true) {
socket?.let {
val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(audioManager)
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
var data: ByteArray = byteArrayOf()
if (bytesRead > 0) {
data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead))
})
val bytes = buffer.copyOfRange(0, bytesRead)
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPods Data", "Data received: $formattedHex")
}
else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
var inEar = false
var inEarData = listOf<Boolean>()
if (earDetectionNotification.isEarDetectionData(data)) {
earDetectionNotification.setStatus(data)
sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
val list = earDetectionNotification.status
val bytes = ByteArray(2)
bytes[0] = list[0]
bytes[1] = list[1]
putExtra("data", bytes)
})
Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
var justEnabledA2dp = false
val earReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data")
if (data != null && earDetectionEnabled) {
inEar = if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
} else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
}
val newInEarData = listOf(data[0] == 0x00.toByte(), data[1] == 0x00.toByte())
if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
connectAudio(this@AirPodsService, device)
justEnabledA2dp = true
val bluetoothAdapter = this@AirPodsService.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(
this@AirPodsService, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(
profile: Int,
proxy: BluetoothProfile
) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices =
proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPlay()
}
}
bluetoothAdapter.closeProfileProxy(
profile,
proxy
)
}
override fun onServiceDisconnected(
profile: Int
) {
}
}
,BluetoothProfile.A2DP
)
}
else if (newInEarData == listOf(false, false)){
disconnectAudio(this@AirPodsService, device)
}
inEarData = newInEarData
if (inEar == true) {
if (!justEnabledA2dp) {
justEnabledA2dp = false
MediaController.sendPlay()
}
} else {
MediaController.sendPause()
}
}
}
}
val earIntentFilter = IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
this@AirPodsService.registerReceiver(earReceiver, earIntentFilter,
RECEIVER_EXPORTED
)
}
else if (ancNotification.isANCData(data)) {
ancNotification.setStatus(data)
sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", ancNotification.status)
})
Log.d("AirPods Parser", "ANC: ${ancNotification.status}")
}
else if (batteryNotification.isBatteryData(data)) {
batteryNotification.setBattery(data)
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
})
for (battery in batteryNotification.getBattery()) {
Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
}
// updatePodsStatus(device!!, batteryNotification.getBattery())
}
else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) {
conversationAwarenessNotification.setData(data)
sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
putExtra("data", conversationAwarenessNotification.status)
})
if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
MediaController.startSpeaking()
} else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
MediaController.stopSpeaking()
}
Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
}
else { }
}
}
Log.d("AirPods Service", "Socket closed")
isRunning = false
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
}
}
}
catch (e: Exception) {
Log.e("AirPodsSettingsScreen", "Error connecting to device: ${e.message}")
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
socket?.close()
isRunning = false
}
}

View File

@@ -1,908 +0,0 @@
package me.kavishdevar.aln
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
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.annotation.RequiresApi
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.luminance
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.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 kotlin.math.roundToInt
@Composable
fun BatteryView() {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
batteryStatus.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableArrayListExtra("data", Battery::class.java) } else { intent.getParcelableArrayListExtra("data") }?.toList() ?: listOf()
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
}
}
Row {
Column (
modifier = Modifier
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
contentDescription = "Buds",
modifier = Modifier
.fillMaxWidth()
.scale(0.50f)
)
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
if ((right?.status == BatteryStatus.CHARGING && left?.status == BatteryStatus.CHARGING) || (left?.status == BatteryStatus.NOT_CHARGING && right?.status == BatteryStatus.NOT_CHARGING))
{
BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING)
}
else {
Row {
if (left?.status != BatteryStatus.DISCONNECTED) {
Text(text = "\uDBC6\uDCE5", fontFamily = FontFamily(Font(R.font.sf_pro)))
BatteryIndicator(left?.level ?: 0, left?.status == BatteryStatus.CHARGING)
Spacer(modifier = Modifier.width(16.dp))
}
if (right?.status != BatteryStatus.DISCONNECTED) {
Text(text = "\uDBC6\uDCE8", fontFamily = FontFamily(Font(R.font.sf_pro)))
BatteryIndicator(right?.level ?: 0, right?.status == BatteryStatus.CHARGING)
}
}
}
}
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = "Case",
modifier = Modifier
.fillMaxWidth()
)
BatteryIndicator(case?.level ?: 0)
}
}
}
@SuppressLint("MissingPermission", "NewApi")
@Composable
fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?,
navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var deviceName by remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", device?.name ?: "") ?: "")) }
// 4B 61 76 69 73 68 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F
val verticalScrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(vertical = 24.dp, horizontal = 12.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
)
) {
LaunchedEffect(service) {
service?.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
if (service != null) {
BatteryView()
Spacer(modifier = Modifier.height(32.dp))
StyledTextField(
name = "Name",
value = deviceName.text,
onValueChange = {
deviceName = TextFieldValue(it)
sharedPreferences.edit().putString("name", it).apply()
service.setName(it)
}
)
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
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))
// val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
// val textColor = if (isDarkTheme) Color.White else Color.Black
// localstorage stuff
// TODO: localstorage and call the setButtons() with previous configuration and new configuration
// Box (
// modifier = Modifier
// .padding(vertical = 8.dp)
// .background(
// if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF),
// RoundedCornerShape(14.dp)
// )
// )
// {
// // TODO: A Column Rows with text at start and a check mark if ticked
// }
Spacer(modifier = Modifier.height(16.dp))
Row (
modifier = Modifier
.background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
.height(55.dp)
.clickable {
navController.navigate("debug")
}
) {
Text(text = "Debug", modifier = Modifier.padding(16.dp), color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { navController.navigate("debug") },
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black ),
modifier = Modifier.padding(start = 16.dp).fillMaxHeight()
) {
@Suppress("DEPRECATION")
Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = "Debug")
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
val sliderValue = remember { mutableFloatStateOf(0f) }
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 isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
val activeTrackColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFF007AFF)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Slider
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
service.setAdaptiveStrength(100 - it.toInt())
},
valueRange = 0f..100f,
onValueChangeFinished = {
// Round the value when the user stops sliding
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
},
modifier = Modifier
.fillMaxWidth()
.height(36.dp), // Adjust height to ensure thumb fits well
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp) // Circular thumb size
.shadow(4.dp, CircleShape) // Apply shadow to the thumb
.background(thumbColor, CircleShape) // Circular thumb
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp)
.background(trackColor, RoundedCornerShape(6.dp))
)
}
)
// Labels
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Less Noise",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(start = 4.dp)
)
Text(
text = "More Noise",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
@Preview
@Composable
fun Preview() {
IndependentToggle("Case Charging Sounds", AirPodsService(), "setCaseChargingSounds", LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE))
}
@Composable
fun IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val textColor = if (isDarkTheme) Color.White else Color.Black
// Standardize the key
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
// State for the toggle
var checked by remember { mutableStateOf(default) }
// Load initial state from SharedPreferences
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
Box (
modifier = Modifier
.padding(vertical = 8.dp)
.background(
if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF),
RoundedCornerShape(14.dp)
)
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp)
.clickable {
// Toggle checked state and save to SharedPreferences
checked = !checked
sharedPreferences
.edit()
.putBoolean(snakeCasedName, checked)
.apply()
// Call the corresponding method in the service
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
},
verticalAlignment = Alignment.CenterVertically
) {
Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
// Call the corresponding method in the service
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, it)
},
)
}
}
}
@Composable
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val textColor = if (isDarkTheme) Color.White else Color.Black
// Load the conversational awareness state from sharedPreferences
var conversationalAwarenessEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness", true)
)
}
// Update the service when the toggle is changed
fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
service.setCAEnabled(enabled)
}
Text(
text = "AUDIO",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF)
val isPressed = remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
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) { // Detect press state for iOS-like effect
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease() // Wait until release
isPressed.value = false
}
)
}
.clickable(
indication = null, // Disable ripple effect
interactionSource = remember { MutableInteractionSource() } // Required for clickable
) {
// Toggle the conversational awareness value
updateConversationalAwareness(!conversationalAwarenessEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Conversational Awareness",
modifier = Modifier.weight(1f),
fontSize = 16.sp,
color = textColor
)
StyledSwitch(
checked = conversationalAwarenessEnabled,
onCheckedChange = {
updateConversationalAwareness(it)
},
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 10.dp)
) {
Text(
text = "Adaptive Audio",
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
Text(
text = "Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.",
modifier = Modifier
.padding(8.dp, top = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(alpha = 0.6f)
)
)
NoiseControlSlider(service = service, sharedPreferences = sharedPreferences)
}
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun NoiseControlSettings(service: AirPodsService) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xFF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
}
}
}
val context = LocalContext.current
val noiseControlIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA)
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
// val paddingAnim by animateDpAsState(
// targetValue = when (noiseControlMode.value) {
// NoiseControlMode.OFF -> 0.dp
// NoiseControlMode.TRANSPARENCY -> 150.dp
// NoiseControlMode.ADAPTIVE -> 250.dp
// NoiseControlMode.NOISE_CANCELLATION -> 350.dp
// }, label = ""
// )
val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode) {
noiseControlMode.value = mode
service.setANCMode(mode.ordinal+1)
when (mode) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
d3a.floatValue = 0f
}
NoiseControlMode.OFF -> {
d1a.floatValue = 0f
d2a.floatValue = 1f
d3a.floatValue = 1f
}
NoiseControlMode.ADAPTIVE -> {
d1a.floatValue = 1f
d2a.floatValue = 0f
d3a.floatValue = 0f
}
NoiseControlMode.TRANSPARENCY -> {
d1a.floatValue = 0f
d2a.floatValue = 0f
d3a.floatValue = 1f
}
}
}
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(75.dp)
.padding(8.dp)
) {
// Box(
// modifier = Modifier
// .fillMaxHeight()
// .width(80.dp)
// .offset(x = paddingAnim)
// .background(selectedBackground, RoundedCornerShape(8.dp))
// )
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.OFF) selectedBackground else Color.Transparent,
modifier = Modifier.weight(1f)
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) selectedBackground else Color.Transparent,
modifier = Modifier.weight(1f)
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) selectedBackground else Color.Transparent,
modifier = Modifier.weight(1f)
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) selectedBackground else Color.Transparent,
modifier = Modifier.weight(1f)
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 1.dp)
) {
Text(
text = "Off",
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = "Transparency",
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = "Adaptive",
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = "Noise Cancellation",
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
fun NoiseControlButton(
icon: ImageBitmap,
onClick: () -> Unit,
textColor: Color,
backgroundColor: Color,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxHeight()
.padding(horizontal = 4.dp, vertical = 4.dp)
.background(color = backgroundColor, shape = RoundedCornerShape(11.dp))
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
bitmap = icon,
contentDescription = null,
tint = textColor,
modifier = Modifier.size(40.dp)
)
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
@Composable
fun StyledSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val thumbColor = Color.White
val trackColor = if (checked) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
// Animate the horizontal offset of the thumb
val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test")
Box(
modifier = Modifier
.width(51.dp)
.height(31.dp)
.clip(RoundedCornerShape(15.dp))
.background(trackColor) // Dynamic track background
.padding(horizontal = 3.dp),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.offset(x = thumbOffsetX) // Animate the offset for smooth transition
.size(27.dp)
.clip(CircleShape)
.background(thumbColor) // Dynamic thumb color
.clickable { onCheckedChange(!checked) } // Make the switch clickable
)
}
}
@Composable
fun StyledTextField(
name: String,
value: String,
onValueChange: (String) -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isFocused) { // Show cursor only when focused
if (isDarkTheme) Color.White else Color.Black
} else {
Color.Transparent // Hide cursor when not focused
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
color = textColor,
fontSize = 16.sp,
),
cursorBrush = SolidColor(cursorColor), // Dynamic cursor color based on focus
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
innerTextField()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused // Update focus state
}
)
}
}
@Composable
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
val batteryOutlineColor = Color(0xFFBFBFBF) // Light gray outline
val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C)
val batteryTextColor = MaterialTheme.colorScheme.onSurface
// Battery indicator dimensions
val batteryWidth = 30.dp
val batteryHeight = 15.dp
val batteryCornerRadius = 4.dp
val tipWidth = 5.dp
val tipHeight = batteryHeight * 0.3f
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Row for battery icon and tip
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = 4.dp) // Padding between icon and percentage text
) {
// Battery Icon
Box(
modifier = Modifier
.width(batteryWidth)
.height(batteryHeight)
.border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius))
) {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(2.dp)
.width(batteryWidth * (batteryPercentage / 100f))
.background(batteryFillColor, RoundedCornerShape(2.dp))
)
if (charging) {
Box(
modifier = Modifier
.fillMaxSize(), // Take up the entire size of the outer Box
contentAlignment = Alignment.Center // Center the charging bolt within the Box
) {
Text(
text = "\uDBC0\uDEE6",
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
// Battery Tip (Protrusion)
Box(
modifier = Modifier
.width(tipWidth)
.height(tipHeight)
.padding(start = 1.dp)
.background(
batteryOutlineColor,
RoundedCornerShape(
topStart = 0.dp,
topEnd = 12.dp,
bottomStart = 0.dp,
bottomEnd = 12.dp
)
)
)
}
// Battery Percentage Text
Text(
text = "$batteryPercentage%",
color = batteryTextColor,
style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)
)
}
}

View File

@@ -0,0 +1,188 @@
/*
* 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.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.TRANSPORT_LE
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresPermission
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
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 org.lsposed.hiddenapibypass.HiddenApiBypass
import java.util.UUID
class CustomDevice : ComponentActivity() {
@SuppressLint("MissingPermission", "CoroutineCreationDuringComposition")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ALNTheme {
val connect = remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Custom Device", style = MaterialTheme.typography.titleLarge)
}
}
) { innerPadding ->
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
// val device: BluetoothDevice = manager.adapter.getRemoteDevice("EC:D6:F4:3D:89:B8")
val device: BluetoothDevice = manager.adapter.getRemoteDevice("E7:48:92:3B:7D:A5")
// val socket = device.createInsecureL2capChannel(31)
// val batteryLevel = remember { mutableStateOf("") }
// socket.outputStream.write(byteArrayOf(0x12,0x3B,0x00,0x02, 0x00))
// socket.outputStream.write(byteArrayOf(0x12, 0x3A, 0x00, 0x01, 0x00, 0x08,0x01))
val gatt = device.connectGatt(this, true, object: BluetoothGattCallback() {
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// Step 2: Iterate through the services and characteristics
gatt.services.forEach { service ->
Log.d("GATT", "Service UUID: ${service.uuid}")
service.characteristics.forEach { characteristic ->
characteristic.descriptors.forEach { descriptor ->
Log.d("GATT", " Descriptor UUID: ${descriptor.uuid}: ${gatt.readDescriptor(descriptor)}")
}
}
}
}
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothGatt.STATE_CONNECTED) {
Log.d("GATT", "Connected to GATT server")
gatt.discoverServices() // Discover services after connection
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d("BLE", "Write successful for UUID: ${characteristic.uuid}")
} else {
Log.e("BLE", "Write failed for UUID: ${characteristic.uuid}, status: $status")
}
}
}, TRANSPORT_LE, 1)
if (connect.value) {
try {
gatt.connect()
}
catch (e: Exception) {
e.printStackTrace()
}
connect.value = false
}
Column (
modifier = Modifier.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(16.dp)
)
{
Button(
onClick = { connect.value = true }
)
{
Text("Connect")
}
Button(onClick = {
val characteristicUuid = "94110001-6D9B-4225-A4F1-6A4A7F01B0DE"
val value = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00 ,0x00 ,0x01)
sendWriteRequest(gatt, characteristicUuid, value)
}) {
Text("batteryLevel.value")
}
}
}
}
}
}
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendWriteRequest(
gatt: BluetoothGatt,
characteristicUuid: String,
value: ByteArray
) {
// Retrieve the service containing the characteristic
val service = gatt.services.find { service ->
service.characteristics.any { it.uuid.toString() == characteristicUuid }
}
if (service == null) {
Log.e("GATT", "Service containing characteristic UUID $characteristicUuid not found.")
return
}
// Retrieve the characteristic
val characteristic = service.getCharacteristic(UUID.fromString(characteristicUuid))
if (characteristic == null) {
Log.e("GATT", "Characteristic with UUID $characteristicUuid not found.")
return
}
// Send the write request
val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
} else {
gatt.writeCharacteristic(characteristic)
}
Log.d("GATT", "Write request sent $success to UUID: $characteristicUuid")
}

View File

@@ -1,225 +0,0 @@
package me.kavishdevar.aln
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.os.Build
import android.os.IBinder
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
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.ArrowBack
import androidx.compose.material.icons.filled.Send
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.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun DebugScreen(navController: NavController) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Debug") },
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
}
}
)
},
containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
val text = remember { mutableStateListOf<String>("Log Start") }
val context = LocalContext.current
val listState = rememberLazyListState()
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data")
data?.let {
text.add(">" + it.joinToString(" ") { byte -> "%02X".format(byte) }) // Use ">" for received packets
}
}
}
LaunchedEffect(context) {
val intentFilter = IntentFilter(AirPodsNotifications.AIRPODS_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED)
}
}
LaunchedEffect(text.size) {
if (text.isNotEmpty()) {
listState.animateScrollToItem(text.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding(), // Ensures padding for keyboard visibility
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = {
items(text.size) { index ->
val message = text[index]
val isSent = message.startsWith(">")
val backgroundColor = if (isSent) Color(0xFFE1FFC7) else Color(0xFFD1D1D1)
Box(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(backgroundColor, RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (!isSent) {
Text("<", color = Color(0xFF00796B), fontSize = 16.sp)
}
Text(
text = if (isSent) message.substring(1) else message, // Remove the ">" from sent packets
fontFamily = FontFamily(Font(R.font.hack)),
color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000)
else Color(0xFF000000),
modifier = Modifier.weight(1f) // Allows text to take available space
)
if (isSent) {
Text(">", color = Color(0xFF00796B), fontSize = 16.sp)
}
}
}
}
}
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
Log.d("AirPodsService", "Service connected")
}
override fun onServiceDisconnected(name: ComponentName) {
airPodsService.value = null
}
}
val intent = Intent(context, AirPodsService::class.java)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) 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 for the input field
trailingIcon = {
IconButton(
onClick = {
airPodsService.value?.sendPacket(packet.value.text)
text.add(packet.value.text) // Add sent message directly without prefix
packet.value = TextFieldValue("") // Clear input field after sending
}
) {
@Suppress("DEPRECATION")
Icon(Icons.Filled.Send, contentDescription = "Send")
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
unfocusedContainerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black,
unfocusedTextColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black.copy(alpha = 0.6f),
focusedLabelColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White.copy(alpha = 0.6f) else Color.Black,
unfocusedLabelColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
),
shape = RoundedCornerShape(12.dp)
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
Log.d("AirPodsService", "Service connected")
}
override fun onServiceDisconnected(name: ComponentName) {
airPodsService.value = null
}
}
val intent = Intent(context, AirPodsService::class.java)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
}
}

View File

@@ -1,57 +1,78 @@
/*
* 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.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
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.IntentFilter
import android.content.ServiceConnection
import android.os.Build
import android.content.SharedPreferences
import android.os.Bundle
import android.os.IBinder
import android.os.ParcelUuid
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.tween
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.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.getSystemService
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.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
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() {
@@ -59,209 +80,210 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val topAppBarTitle = remember { mutableStateOf("AirPods Pro") }
ALNTheme {
Scaffold (
containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = topAppBarTitle.value,
color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black,
)
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
)
)
}
) { innerPadding ->
Main(innerPadding, topAppBarTitle)
}
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")
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState<String>) {
val bluetoothConnectPermissionState = rememberPermissionState(
permission = "android.permission.BLUETOOTH_CONNECT"
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"
)
)
if (bluetoothConnectPermissionState.status.isGranted) {
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
if (permissionState.allPermissionsGranted) {
val context = LocalContext.current
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
val bluetoothManager = getSystemService(context, BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager?.adapter
val airpodsDevice = remember { mutableStateOf<BluetoothDevice?>(null) }
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val navController = rememberNavController()
val disconnectReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
navController.navigate("notConnected")
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)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(disconnectReceiver, IntentFilter(AirPodsNotifications.AIRPODS_DISCONNECTED),
Context.RECEIVER_NOT_EXPORTED)
}
// Service connection for AirPodsService
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
Log.d("AirPodsService", "Service connected")
}
override fun onServiceDisconnected(name: ComponentName) {
airPodsService.value = null
}
}
// Function to check if AirPods are connected
fun checkIfAirPodsConnected() {
val devices = bluetoothAdapter?.bondedDevices
devices?.forEach { device ->
if (device.uuids.contains(uuid)) {
bluetoothAdapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
airpodsDevice.value = device
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
topAppBarTitle.value = sharedPreferences.getString("name", device.name) ?: device.name
// Start AirPods service if not running
if (context.getSystemService(AirPodsService::class.java)?.isRunning != true) {
context.startService(Intent(context, AirPodsService::class.java).apply {
putExtra("device", device)
})
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
}
} else {
airpodsDevice.value = null
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
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 = "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
)
)
}
}
}
// BroadcastReceiver to listen for connection state changes
val bluetoothReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action
val device = intent?.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
if (action == BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)) {
BluetoothAdapter.STATE_CONNECTED -> {
if (device?.uuids?.contains(uuid) == true) {
airpodsDevice.value = device
checkIfAirPodsConnected()
}
}
BluetoothAdapter.STATE_DISCONNECTED -> {
if (device?.uuids?.contains(uuid) == true) {
airpodsDevice.value = null
// Show not connected screen when AirPods disconnect
navController.navigate("notConnected")
}
}
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
}
}
// Register the receiver in LaunchedEffect
LaunchedEffect(Unit) {
val filter = IntentFilter().apply {
addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(bluetoothReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
}
// Initial check for AirPods connection
checkIfAirPodsConnected()
}
// UI logic
NavHost(
navController = navController,
startDestination = "notConnected",
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) }
) {
composable("notConnected") {
Text("Not Connected...")
}
composable("settings") {
AirPodsSettingsScreen(
paddingValues,
airpodsDevice.value,
service = airPodsService.value,
navController = navController
)
}
composable("debug") {
DebugScreen(navController = navController)
}
}
// Automatically navigate to settings screen if AirPods are connected
if (airpodsDevice.value != null) {
LaunchedEffect(Unit) {
navController.navigate("settings") {
popUpTo("notConnected") { inclusive = true }
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)
}
}
} else {
Text("No AirPods connected")
}
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 {
// Permission is not granted, request it
Column (
modifier = Modifier.padding(24.dp),
){
val textToShow = if (bluetoothConnectPermissionState.status.shouldShowRationale) {
val textToShow = if (permissionState.shouldShowRationale) {
// If the user has denied the permission but not permanently, explain why it's needed.
"The BLUETOOTH_CONNECT permission is important for this app. Please grant it to proceed."
"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.
"BLUETOOTH_CONNECT permission required for this feature. Please enable it in settings."
"Please enable Bluetooth and Notification permissions in the app settings to use the app."
}
Text(textToShow)
Button(onClick = { bluetoothConnectPermissionState.launchPermissionRequest() }) {
Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
Text("Request permission")
}
}
}
}
@PreviewLightDark
@Composable
fun PreviewAirPodsSettingsScreen() {
AirPodsSettingsScreen(paddingValues = PaddingValues(0.dp), device = null, service = null, navController = rememberNavController())
}

View File

@@ -1,55 +0,0 @@
package me.kavishdevar.aln
import android.media.AudioManager
import android.util.Log
import android.view.KeyEvent
object MediaController {
private var initialVolume: Int? = null // Nullable to track the unset state
private lateinit var audioManager: AudioManager // Declare AudioManager
// Initialize the singleton with the AudioManager instance
fun initialize(audioManager: AudioManager) {
this.audioManager = audioManager
}
@Synchronized
fun sendPause() {
if (audioManager.isMusicActive) {
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE))
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE))
}
}
@Synchronized
fun sendPlay() {
if (!audioManager.isMusicActive) {
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY))
audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY))
}
}
@Synchronized
fun startSpeaking() {
Log.d("MediaController", "Starting speaking")
if (initialVolume == null) {
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
Log.d("MediaController", "Initial Volume Set: $initialVolume")
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
1, // Set to a lower volume when speaking starts
0
)
}
Log.d("MediaController", "Initial Volume: $initialVolume")
}
@Synchronized
fun stopSpeaking() {
Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume")
initialVolume?.let { volume ->
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0)
initialVolume = null // Reset to null after restoring the volume
}
}
}

View File

@@ -1,74 +0,0 @@
package me.kavishdevar.aln
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.ParcelUuid
class StartupReceiver : BroadcastReceiver() {
companion object {
val PodsUUIDS: Set<ParcelUuid> = setOf(
ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"),
ParcelUuid.fromString("2a72e02b-7b99-778f-014d-ad0b7221ec74")
)
val btActions: Set<String> = setOf(
BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED,
BluetoothDevice.ACTION_ACL_CONNECTED,
BluetoothDevice.ACTION_ACL_DISCONNECTED,
BluetoothDevice.ACTION_BOND_STATE_CHANGED,
BluetoothDevice.ACTION_NAME_CHANGED
)
}
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return
intent.action?.let { action ->
if (btActions.contains(action)) {
try {
val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothAdapter.ERROR)
val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
device?.let {
btProfileChanges(context, state, it)
}
} catch (e: NullPointerException) {
}
}
}
}
@SuppressLint("MissingPermission")
private fun isPods(device: BluetoothDevice): Boolean {
device.uuids?.forEach { uuid ->
if (PodsUUIDS.contains(uuid)) {
return true
}
}
return false
}
private fun startPodsService(context: Context, device: BluetoothDevice) {
if (!isPods(device)) return
val intent = Intent(context, AirPodsService::class.java).apply {
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
}
context.startService(intent)
}
private fun stopPodsService(context: Context) {
context.stopService(Intent(context, AirPodsService::class.java))
}
private fun btProfileChanges(context: Context, state: Int, device: BluetoothDevice) {
when (state) {
BluetoothProfile.STATE_CONNECTED -> startPodsService(context, device)
BluetoothProfile.STATE_DISCONNECTED, BluetoothProfile.STATE_DISCONNECTING -> stopPodsService(context)
}
}
}

View File

@@ -0,0 +1,196 @@
/*
* 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.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.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
@Composable
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Text(
text = stringResource(R.string.accessibility).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(
text = stringResource(R.string.tone_volume),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
}
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions,
selectedOption = selectedPressSpeed,
onOptionSelected = {
selectedPressSpeed = it
service.setPressSpeed(pressSpeedOptions.indexOf(it))
},
textColor = textColor
)
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions,
selectedOption = selectedPressAndHoldDuration,
onOptionSelected = {
selectedPressAndHoldDuration = it
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
},
textColor = textColor
)
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions,
selectedOption = selectedVolumeSwipeSpeed,
onOptionSelected = {
selectedVolumeSwipeSpeed = it
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
},
textColor = textColor
)
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
}
}
@Composable
fun DropdownMenuComponent(
label: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
textColor: Color
) {
var expanded by remember { mutableStateOf(false) }
Column (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }
.padding(8.dp)
) {
Text(
text = selectedOption,
modifier = Modifier.padding(16.dp),
color = textColor
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
onClick = {
onOptionSelected(option)
expanded = false
},
text = { Text(text = option) }
)
}
}
}
}
@Preview
@Composable
fun AccessibilitySettingsPreview() {
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}

View File

@@ -0,0 +1,155 @@
/*
* 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.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.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.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 kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
val sliderValue = remember { mutableFloatStateOf(0f) }
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 isDarkTheme = isSystemInDarkTheme()
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
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
},
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
},
modifier = Modifier
.fillMaxWidth()
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
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))
)
}
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Less Noise",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(start = 4.dp)
)
Text(
text = "More Noise",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
@Preview
@Composable
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}

View File

@@ -0,0 +1,107 @@
/*
* 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.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.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
@Composable
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = stringResource(R.string.audio).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 10.dp)
) {
Text(
text = stringResource(R.string.adaptive_audio),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
Text(
text = stringResource(R.string.adaptive_audio_description),
modifier = Modifier
.padding(bottom = 8.dp, top = 2.dp)
.padding(end = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(alpha = 0.6f)
)
)
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
}
}
}
@Preview
@Composable
fun AudioSettingsPreview() {
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}

View File

@@ -0,0 +1,135 @@
/*
* 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 androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.R
@Composable
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
val batteryOutlineColor = Color(0xFFBFBFBF)
val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C)
val batteryTextColor = MaterialTheme.colorScheme.onSurface
val batteryWidth = 40.dp
val batteryHeight = 15.dp
val batteryCornerRadius = 4.dp
val tipWidth = 5.dp
val tipHeight = batteryHeight * 0.375f
val animatedFillWidth by animateFloatAsState(targetValue = batteryPercentage / 100f)
val animatedScale by animateFloatAsState(targetValue = if (charging) 1.2f else 1f)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = 4.dp)
) {
Box(
modifier = Modifier
.width(batteryWidth)
.height(batteryHeight)
) {
Box (
modifier = Modifier
.fillMaxSize()
.border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius))
)
Box(
modifier = Modifier
.fillMaxHeight()
.padding(2.dp)
.width(batteryWidth * animatedFillWidth)
.background(batteryFillColor, RoundedCornerShape(2.dp))
)
if (charging) {
Text(
text = "\uDBC0\uDEE6",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White,
modifier = Modifier
.scale(animatedScale)
.fillMaxSize(),
textAlign = TextAlign.Center
)
}
}
Box(
modifier = Modifier
.width(tipWidth)
.height(tipHeight)
.padding(start = 1.dp)
.background(
batteryOutlineColor,
RoundedCornerShape(
topStart = 0.dp,
topEnd = 12.dp,
bottomStart = 0.dp,
bottomEnd = 12.dp
)
)
)
}
Text(
text = "$batteryPercentage%",
color = batteryTextColor,
style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)
)
}
}
@Preview
@Composable
fun BatteryIndicatorPreview() {
BatteryIndicator(batteryPercentage = 48, charging = true)
}

View File

@@ -0,0 +1,185 @@
/*
* 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.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.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
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
batteryStatus.value =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
intent.getParcelableArrayListExtra("data")
}?.toList() ?: listOf()
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
}
catch (_: IllegalArgumentException) {
Log.wtf("BatteryReceiver", "Receiver already unregistered")
}
}
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter()
.apply {
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
batteryReceiver,
batteryIntentFilter,
Context.RECEIVER_EXPORTED
)
}
}
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf<Battery>(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING)
)
}
Row {
Column (
modifier = Modifier
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.scale(0.80f)
)
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
if ((right?.status == BatteryStatus.CHARGING && left?.status == BatteryStatus.CHARGING) || (left?.status == BatteryStatus.NOT_CHARGING && right?.status == BatteryStatus.NOT_CHARGING))
{
BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING)
}
else {
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
// if (left?.status != BatteryStatus.DISCONNECTED) {
if (left?.level != null) {
BatteryIndicator(
left.level,
left.status == BatteryStatus.CHARGING
)
}
// }
// if (left?.status != BatteryStatus.DISCONNECTED && right?.status != BatteryStatus.DISCONNECTED) {
if (left?.level != null && right?.level != null)
{
Spacer(modifier = Modifier.width(16.dp))
}
// }
// if (right?.status != BatteryStatus.DISCONNECTED) {
if (right?.level != null)
{
BatteryIndicator(
right.level,
right.status == BatteryStatus.CHARGING
)
}
// }
}
}
}
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.scale(1.25f)
)
// if (case?.status != BatteryStatus.DISCONNECTED) {
if (case?.level != null) {
BatteryIndicator(case.level, case.status == BatteryStatus.CHARGING)
}
// }
}
}
}
@Preview
@Composable
fun BatteryViewPreview() {
BatteryView(AirPodsService(), preview = true)
}

View File

@@ -0,0 +1,125 @@
/*
* 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 ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var conversationalAwarenessEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness", true)
)
}
fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
service.setCAEnabled(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() }
) {
updateConversationalAwareness(!conversationalAwarenessEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Conversational Awareness",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Lowers media volume and reduces background noise when you start speaking to other people.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = conversationalAwarenessEnabled,
onCheckedChange = {
updateConversationalAwareness(it)
},
)
}
}
@Preview
@Composable
fun ConversationalAwarenessSwitchPreview() {
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -0,0 +1,182 @@
/*
* 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 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.aln.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

@@ -0,0 +1,113 @@
/*
* 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.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.Box
import androidx.compose.foundation.layout.Row
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.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.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 IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
var checked by remember { mutableStateOf(default) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
Box (
modifier = Modifier
.padding(vertical = 8.dp)
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
sharedPreferences
.edit()
.putBoolean(snakeCasedName, checked)
.apply()
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
}
)
},
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
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)
},
)
}
}
}
@Preview
@Composable
fun IndependentTogglePreview() {
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
}

View File

@@ -0,0 +1,126 @@
/*
* 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

@@ -0,0 +1,154 @@
/*
* 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 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.Box
import androidx.compose.foundation.layout.Row
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.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
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
@Composable
fun NameField(
name: String,
value: String,
navController: NavController
) {
var isFocused by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isFocused) {
if (isDarkTheme) Color.White else Color.Black
} else {
Color.Transparent
}
Box (
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
navController.navigate("rename")
}
)
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(
animatedBackgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
BasicTextField(
value = value,
textStyle = TextStyle(
color = textColor.copy(alpha = 0.75f),
fontSize = 16.sp,
textAlign = TextAlign.End
),
onValueChange = {},
singleLine = true,
enabled = false,
cursorBrush = SolidColor(cursorColor),
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
innerTextField()
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Edit name",
tint = textColor.copy(alpha = 0.75f),
modifier = Modifier
.size(32.dp)
)
}
}
)
}
}
}
@Preview
@Composable
fun StyledTextFieldPreview() {
NameField(name = "Name", value = "AirPods Pro", rememberNavController())
}

View File

@@ -0,0 +1,104 @@
/*
* 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 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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
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.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.navigation.NavController
@Composable
fun NavigationButton(to: String, name: String, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.height(55.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
navController.navigate(to)
}
)
}
) {
Text(
text = name,
modifier = Modifier.padding(16.dp),
color = if (isDarkTheme) Color.White else Color.Black
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { navController.navigate(to) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isDarkTheme) Color.White else Color.Black
),
modifier = Modifier
.padding(start = 16.dp)
.fillMaxHeight()
) {
@Suppress("DEPRECATION")
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = name
)
}
}
}
@Preview
@Composable
fun NavigationButtonPreview() {
NavigationButton("to", "Name", NavController(LocalContext.current))
}

View File

@@ -0,0 +1,77 @@
/*
* 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.aln.R
@Composable
fun NoiseControlButton(
icon: ImageBitmap,
onClick: () -> Unit,
textColor: Color,
modifier: Modifier = Modifier,
usePadding: Boolean = true
) {
Column(
modifier = modifier
.fillMaxHeight()
.then(if (usePadding) Modifier.padding(horizontal = 4.dp, vertical = 4.dp) else Modifier)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
bitmap = icon,
contentDescription = null,
tint = textColor,
modifier = Modifier.size(40.dp)
)
}
}
@Preview
@Composable
fun NoiseControlButtonPreview() {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = {},
textColor = Color.White,
)
}

View File

@@ -0,0 +1,433 @@
/*
* 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.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
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
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.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 kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
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 isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
if (!received && !offListeningMode.value && mode == NoiseControlMode.OFF) {
noiseControlMode.value = NoiseControlMode.ADAPTIVE
} else {
noiseControlMode.value = mode
}
if (!received) service.setANCMode(mode.ordinal + 1)
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
d3a.floatValue = 0f
}
NoiseControlMode.OFF -> {
d1a.floatValue = 0f
d2a.floatValue = 1f
d3a.floatValue = 1f
}
NoiseControlMode.ADAPTIVE -> {
d1a.floatValue = 1f
d2a.floatValue = 0f
d3a.floatValue = 0f
}
NoiseControlMode.TRANSPARENCY -> {
d1a.floatValue = 0f
d2a.floatValue = 0f
d3a.floatValue = 1f
}
}
}
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
onModeSelected(noiseControlMode.value, true)
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
val noiseControlIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.ANC_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Text(
text = stringResource(R.string.noise_control).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
val density = LocalDensity.current
val buttonCount = if (offListeningMode.value) 4 else 3
val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) }
var dragOffset by remember {
mutableFloatStateOf(
with(density) {
when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
}
}
)
}
val animationSpec: AnimationSpec<Float> = SpringSpec(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 0.01f
)
val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
}
val animatedOffset by animateFloatAsState(
targetValue = with(density) {
if (isDragging.value) dragOffset else targetOffset.toPx()
},
animationSpec = animationSpec,
label = "selector"
)
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(backgroundColor, RoundedCornerShape(14.dp))
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
if (offListeningMode.value) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
Box(
modifier = Modifier
.width(buttonWidth)
.fillMaxHeight()
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.zIndex(0f)
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
dragOffset = (dragOffset + delta).coerceIn(
0f,
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
)
},
onDragStarted = { isDragging.value = true },
onDragStopped = {
isDragging.value = false
val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt()
val newMode = when(newIndex) {
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
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
}
newMode?.let { onModeSelected(it) }
}
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(3.dp)
.background(selectedBackground, RoundedCornerShape(12.dp))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
) {
if (offListeningMode.value) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.padding(top = 4.dp)
) {
if (offListeningMode.value) {
Text(
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
}
Text(
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.noise_cancellation),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Preview()
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}

View File

@@ -0,0 +1,126 @@
/*
* 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

@@ -0,0 +1,210 @@
/*
* 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 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.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
@Composable
fun PressAndHoldSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888)
var leftBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
var rightBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animationSpec = tween<Color>(durationMillis = 500)
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
Text(
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
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 = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(animatedLeftBackgroundColor, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
leftBackgroundColor = dividerColor
tryAwaitRelease()
leftBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
navController.navigate("long_press/Left")
}
)
},
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.left),
style = TextStyle(
fontSize = 18.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
IconButton(
onClick = {
navController.navigate("long_press/Left")
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "go",
tint = textColor
)
}
}
}
HorizontalDivider(
thickness = 1.5.dp,
color = dividerColor,
modifier = Modifier
.padding(start = 16.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(animatedRightBackgroundColor, RoundedCornerShape(bottomEnd = 14.dp, bottomStart = 14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
rightBackgroundColor = dividerColor
tryAwaitRelease()
rightBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
navController.navigate("long_press/Right")
}
)
},
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier
.padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.right),
style = TextStyle(
fontSize = 18.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
IconButton(
onClick = {
navController.navigate("long_press/Right")
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "go",
tint = textColor
)
}
}
}
}
}
@Preview
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}

View File

@@ -0,0 +1,125 @@
/*
* 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 SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var singleANCEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("single_anc", true)
)
}
fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
service.setNoiseCancellationWithOnePod(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() }
) {
updateSingleEnabled(!singleANCEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Noise Cancellation with Single AirPod",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = singleANCEnabled,
onCheckedChange = {
updateSingleEnabled(it)
},
)
}
}
@Preview
@Composable
fun SinglePodANCSwitchPreview() {
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -0,0 +1,88 @@
/*
* 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 androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
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.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun StyledSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
) {
val isDarkTheme = isSystemInDarkTheme()
val thumbColor = Color.White
val trackColor = if (enabled) (
if (isDarkTheme) {
if (checked) Color(0xFF34C759) else Color(0xFF5B5B5E)
} else {
if (checked) Color(0xFF34C759) else Color(0xFFD1D1D6)
}
) else {
if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
}
val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test")
Box(
modifier = Modifier
.width(51.dp)
.height(31.dp)
.clip(RoundedCornerShape(15.dp))
.background(trackColor) // Dynamic track background
.padding(horizontal = 3.dp),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.offset(x = thumbOffsetX)
.size(27.dp)
.clip(CircleShape)
.background(thumbColor)
.clickable { if (enabled) onCheckedChange(!checked) }
)
}
}
@Preview
@Composable
fun StyledSwitchPreview() {
StyledSwitch(checked = true, onCheckedChange = {})
}

View File

@@ -0,0 +1,160 @@
/*
* 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.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
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 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()
}
val isDarkTheme = isSystemInDarkTheme()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uDBC0\uDEA1",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(start = 4.dp)
)
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
service.setToneVolume(volume = it.toInt())
},
valueRange = 0f..100f,
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 / 100)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = "\uDBC0\uDEA9",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
@Preview
@Composable
fun ToneVolumeSliderPreview() {
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -0,0 +1,270 @@
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

@@ -0,0 +1,124 @@
/*
* 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 VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var volumeControlEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("volume_control", true)
)
}
fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
service.setVolumeControl(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() }
) {
updateVolumeControlEnabled(!volumeControlEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Volume Control",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = volumeControlEnabled,
onCheckedChange = {
updateVolumeControlEnabled(it)
},
)
}
}
@Preview
@Composable
fun VolumeControlSwitchPreview() {
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -0,0 +1,43 @@
/*
* 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.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import me.kavishdevar.aln.services.AirPodsService
class BootReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_MY_PACKAGE_REPLACED -> try { context?.startForegroundService(
Intent(
context,
AirPodsService::class.java
)
) } catch (e: Exception) { e.printStackTrace() }
Intent.ACTION_BOOT_COMPLETED -> try { context?.startForegroundService(
Intent(
context,
AirPodsService::class.java
)
) } catch (e: Exception) { e.printStackTrace() }
}
}
}

View File

@@ -0,0 +1,393 @@
/*
* 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.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.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.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.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.launch
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", "UnspecifiedRegisterReceiverFlag")
@Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
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() }
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 bluetoothReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(true)
}
} else if (intent?.action == "me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(false)
}
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
addAction("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(bluetoothReceiver, filter)
}
onDispose {
context.unregisterReceiver(bluetoothReceiver)
}
}
@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 > 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 (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

@@ -0,0 +1,363 @@
/*
* 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

@@ -0,0 +1,264 @@
/*
* 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

@@ -0,0 +1,297 @@
/*
* 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

@@ -0,0 +1,201 @@
/*
* 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.isSystemInDarkTheme
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Clear
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.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
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.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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RenameScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
name.value = name.value.copy(selection = TextRange(name.value.text.length))
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.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(
text = name.value.text,
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 = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isDarkTheme) Color.White else Color.Black
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BasicTextField(
value = name.value,
onValueChange = {
name.value = it
sharedPreferences.edit().putString("name", it.text).apply()
ServiceManager.getService()?.setName(it.text)
},
textStyle = TextStyle(
color = textColor,
fontSize = 16.sp,
),
singleLine = true,
cursorBrush = SolidColor(cursorColor),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
innerTextField()
}
IconButton(
onClick = {
name.value = TextFieldValue("")
sharedPreferences.edit().putString("name", "").apply()
ServiceManager.getService()?.setName("")
}
) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
tint = if (isDarkTheme) Color.White else Color.Black
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
}
}
}
@Preview
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,22 @@
/*
* 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.ui.theme
import androidx.compose.ui.graphics.Color

View File

@@ -1,3 +1,21 @@
/*
* 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.ui.theme
import android.os.Build
@@ -20,7 +38,6 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),

View File

@@ -1,3 +1,21 @@
/*
* 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.ui.theme
import androidx.compose.material3.Typography

View File

@@ -0,0 +1,277 @@
/*
* 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.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
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 java.io.IOException
import java.util.UUID
enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
}
object CrossDevice {
var initialized = false
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
private var serverSocket: BluetoothServerSocket? = null
private var clientSocket: BluetoothSocket? = null
private lateinit var bluetoothAdapter: BluetoothAdapter
private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
private const val MANUFACTURER_ID = 0x1234
private const val MANUFACTURER_DATA = "ALN_AirPods"
var isAvailable: Boolean = false // set to true when airpods are connected to another device
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
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) {
CoroutineScope(Dispatchers.IO).launch {
Log.d("CrossDevice", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startAdvertising()
startServer()
initialized = true
}
}
@SuppressLint("MissingPermission")
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
if (!bluetoothAdapter.isEnabled) return@launch
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
try {
val socket = serverSocket!!.accept()
handleClientConnection(socket)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
}
@SuppressLint("MissingPermission")
private fun startAdvertising() {
CoroutineScope(Dispatchers.IO).launch {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
Log.d("CrossDevice", "BLE Advertising started")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d("CrossDevice", "BLE Advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
}
}
fun setAirPodsConnected(connected: Boolean) {
if (connected) {
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
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)
}
private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex"
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry)
sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply()
}
@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) {
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) || 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()
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
} else {
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: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
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()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
val newEarDetectionStatus = listOf(
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.aln.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
}
}
}
}
}
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream == null) {
Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
Log.d("CrossDevice", "Sent packet to remote device")
}
fun notifyAirPodsConnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
fun notifyAirPodsDisconnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
}

View File

@@ -0,0 +1,156 @@
/*
* 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
enum class IslandType {
CONNECTED,
TAKING_OVER,
MOVED_TO_REMOTE
}
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, 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()
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 (type == IslandType.TAKING_OVER) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (type == IslandType.MOVED_TO_REMOTE) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
} else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else if (type == IslandType.CONNECTED) {
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

@@ -0,0 +1,177 @@
/*
* 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.content.SharedPreferences
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import me.kavishdevar.aln.services.ServiceManager
object MediaController {
private var initialVolume: Int? = null
private lateinit var audioManager: AudioManager
var iPausedTheMedia = false
var userPlayedTheMedia = false
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
var pausedForCrossDevice = false
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
if (this::audioManager.isInitialized) {
return
}
this.audioManager = audioManager
this.sharedPreferences = sharedPreferences
Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
sharedPreferences.registerOnSharedPreferenceChangeListener { _, 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)
}
"conversational_awareness_pause_music" -> {
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
}
}
}
audioManager.registerAudioPlaybackCallback(cb, null)
}
val cb = object : AudioManager.AudioPlaybackCallback() {
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.")
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", "pausedforcrossdevice: $pausedForCrossDevice 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) {
Log.d("MediaController", "Pausing for cross device and taking over.")
sendPause(true)
pausedForCrossDevice = true
ServiceManager.getService()?.takeOver()
}
}
}
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
iPausedTheMedia = true
userPlayedTheMedia = false
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_PAUSE
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_PAUSE
)
)
}
}
@Synchronized
fun sendPlay() {
Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia")
if (iPausedTheMedia) {
Log.d("MediaController", "Sending play and setting userPlayedTheMedia to false")
userPlayedTheMedia = false
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_PLAY
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_PLAY
)
)
}
}
@Synchronized
fun startSpeaking() {
Log.d("MediaController", "Starting speaking")
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!!
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
if (conversationalAwarenessPauseMusic) {
sendPause(force = true)
}
}
Log.d("MediaController", "Initial Volume: $initialVolume")
}
@Synchronized
fun stopSpeaking() {
Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume")
if (initialVolume != null) {
smoothVolumeTransition(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC), initialVolume!!)
if (conversationalAwarenessPauseMusic) {
sendPlay()
}
initialVolume = null
}
}
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
val step = if (fromVolume < toVolume) 1 else -1
val delay = 50L
var currentVolume = fromVolume
handler.post(object : Runnable {
override fun run() {
if (currentVolume != toVolume) {
currentVolume += step
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0)
handler.postDelayed(this, delay)
}
}
})
}
}

View File

@@ -1,6 +1,28 @@
package me.kavishdevar.aln
/*
* 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) {
@@ -59,6 +81,10 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED"
@@ -68,6 +94,8 @@ class AirPodsNotifications {
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 {
@@ -106,6 +134,9 @@ class AirPodsNotifications {
}
fun setStatus(data: ByteArray) {
if (data.size != 11) {
return
}
status = data[7].toInt()
}
@@ -126,18 +157,35 @@ class AirPodsNotifications {
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean {
if (data.size != 22) {
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
}
return data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() &&
data[3] == 0x00.toByte() && data[4] == 0x04.toByte() && data[5] == 0x00.toByte()
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) {
first = Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
second = Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
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(data[17].toInt(), case.level, data[20].toInt())
Battery(case.component, case.level, data[20].toInt())
} else {
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
}
@@ -151,6 +199,7 @@ class AirPodsNotifications {
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0
@@ -190,4 +239,49 @@ class Capabilities {
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

@@ -0,0 +1,184 @@
/*
* 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

@@ -0,0 +1,41 @@
/*
* 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.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
class BatteryWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
ServiceManager.getService()?.updateBatteryWidget()
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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.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 me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
class NoiseControlWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
val views = RemoteViews(context.packageName, R.layout.noise_control_widget)
val offIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 1)
}
val transparencyIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 3)
}
val adaptiveIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 4)
}
val ancIntent = Intent(context, NoiseControlWidget::class.java).apply {
action = "ACTION_SET_ANC_MODE"
putExtra("ANC_MODE", 2)
}
views.setOnClickPendingIntent(
R.id.widget_off_button,
PendingIntent.getBroadcast(context, 0, offIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_transparency_button,
PendingIntent.getBroadcast(context, 1, transparencyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_adaptive_button,
PendingIntent.getBroadcast(context, 2, adaptiveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
views.setOnClickPendingIntent(
R.id.widget_anc_button,
PendingIntent.getBroadcast(context, 3, ancIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
)
ServiceManager.getService()?.updateNoiseControlWidget()
appWidgetManager.updateAppWidget(appWidgetIds, views)
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
ServiceManager.getService()?.setANCMode(mode)
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
Background for widgets to make the rounded corners based on the
appWidgetRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetRadius" />
<solid android:color="?android:attr/colorBackground" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!--
Background for views inside widgets to make the rounded corners based on the
appWidgetInnerRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetInnerRadius" />
<solid android:color="?android:attr/colorAccent" />
</shape>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="43dp" android:viewportHeight="607.69" android:viewportWidth="902.34" android:width="63.849365dp">
<path android:fillAlpha="0" android:fillColor="#FF000000" android:pathData="M0,0h902.34v607.69h-902.34z" android:strokeAlpha="0"/>
<path android:fillAlpha="0.85" android:fillColor="#ffffff" android:pathData="M315.92,550.31C315.92,567.88 304.69,577.41 286.13,577.41L261.48,577.41C242.92,577.41 231.45,567.88 231.45,550.31L231.45,358.73C267.25,355.98 293.73,349.94 315.92,341.64ZM670.9,358.73L670.9,550.31C670.9,567.88 659.42,577.41 640.87,577.41L616.21,577.41C597.66,577.41 586.43,567.88 586.43,550.31L586.43,341.64C608.62,349.94 635.09,355.98 670.9,358.73Z"/>
<path android:fillAlpha="0.85" android:fillColor="#ffffff" android:pathData="M429.2,153.09C429.2,221.45 388.18,270.28 335.2,299.57C312.69,312 288.17,321.38 249.56,326.11C255.64,311.65 259.03,294.95 259.03,276.14C259.03,213.57 207.98,151 137.53,139.93C143.33,128.37 149.48,118.78 153.81,113.29C192.14,56.65 252.44,29.55 306.64,30.29C375.24,31.02 429.2,76.43 429.2,153.09ZM748.53,113.29C752.86,118.78 759.01,128.37 764.82,139.93C694.36,151 643.31,213.57 643.31,276.14C643.31,294.95 646.71,311.65 652.78,326.11C614.17,321.38 589.66,312 567.14,299.57C514.16,270.28 473.14,221.45 473.14,153.09C473.14,76.43 527.1,31.02 595.7,30.29C649.9,29.55 710.21,56.65 748.53,113.29ZM346.19,100.11L301.51,137.71C295.41,142.84 294.68,151.62 299.56,157.48C304.69,163.83 313.72,164.32 319.34,159.44L364.75,121.84C370.85,116.71 371.09,107.92 365.97,102.06C361.33,95.72 352.3,94.98 346.19,100.11ZM536.38,102.06C531.25,107.92 531.49,116.71 537.6,121.84L583.01,159.44C588.62,164.32 597.66,163.83 602.78,157.48C607.67,151.62 606.93,142.84 600.83,137.71L556.15,100.11C550.05,94.98 541.02,95.72 536.38,102.06Z"/>
<path android:fillAlpha="0.85" android:fillColor="#ffffff" android:pathData="M140.87,364.76C189.45,364.76 229.98,334.48 229.98,276.14C229.98,222.43 180.18,167.25 114.99,167.25C61.28,167.25 29.05,209.24 29.05,254.65C29.05,320.08 84.72,364.76 140.87,364.76ZM125.98,319.59C117.19,327.16 102.29,319.35 84.23,297.38C66.41,275.89 61.52,260.51 70.07,252.94C79.1,245.37 93.75,252.94 111.82,274.92C129.4,297.13 134.52,312.27 125.98,319.59ZM761.47,364.76C817.63,364.76 873.29,320.08 873.29,254.65C873.29,209.24 841.06,167.25 787.35,167.25C722.17,167.25 672.36,222.43 672.36,276.14C672.36,334.48 712.89,364.76 761.47,364.76ZM776.37,319.59C767.82,312.27 772.95,297.13 790.53,274.92C808.59,252.94 823.24,245.37 832.28,252.94C840.82,260.51 835.94,275.89 818.11,297.38C800.29,319.35 785.16,327.16 776.37,319.59Z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="141.5dp"
android:height="109.06dp"
android:viewportWidth="141.5"
android:viewportHeight="109.06">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h141.5v109.06h-141.5z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M37.88,109L103.63,109C129.19,109 141.5,96.75 141.5,71.31L141.5,41.63L102.81,41.63C101.75,44.44 99.13,46.25 95.81,46.25L45.75,46.25C42.5,46.25 39.75,44.44 38.75,41.63L0,41.63L0,71.31C0,96.75 12.38,109 37.88,109ZM70.75,70.06C67.63,70.13 65.06,67.5 65.06,64.44C65.06,61.31 67.63,58.69 70.75,58.69C73.88,58.69 76.5,61.31 76.5,64.44C76.5,67.38 73.88,70 70.75,70.06ZM0,35.94L38.63,35.94C39.63,33.06 42.38,31.31 45.63,31.31L95.75,31.31C99,31.31 101.69,33.06 102.69,35.94L141.31,35.94L141.31,33.94C141.31,11.13 127.81,0 103.44,0L37.88,0C13.56,0 0,11.13 0,33.94Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="94.19dp"
android:height="123.31dp"
android:viewportWidth="94.19"
android:viewportHeight="123.31">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h94.19v123.31h-94.19z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M46.81,73.29L46.81,111.06C46.81,114.94 44.25,116.94 40.38,116.94L35.5,116.94C31.63,116.94 29.06,114.94 29.06,111.06L29.06,69.82C33.68,71.55 39.25,72.79 46.81,73.29Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M62,22.63C62.94,23.8 64.28,25.84 65.55,28.29C51.19,30.59 40.75,43.46 40.75,56.13C40.75,60.05 41.47,63.51 42.75,66.49C34.56,65.53 29.44,63.52 24.75,60.88C14.44,54.94 6.31,45.31 6.31,31.5C6.31,15.56 17.56,6.5 31.38,6.38C42.06,6.25 54,11.5 62,22.63ZM19.44,21.25C18.31,22.5 18.38,24.38 19.69,25.44L29,33.19C30.19,34.25 32.13,34.13 33.19,32.81C34.25,31.5 34.13,29.63 32.75,28.56L23.63,20.81C22.31,19.75 20.44,19.94 19.44,21.25Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M65.13,74.25C76.31,74.25 87.81,65.38 87.81,51.81C87.81,42.75 81.44,33.88 70.19,33.88C57,33.88 46.81,45.31 46.81,56.13C46.81,68.25 55.38,74.25 65.13,74.25ZM68.19,64.75C66.44,63.25 67.44,60.25 71,55.69C74.69,51.19 77.63,49.69 79.5,51.31C81.19,52.81 80.25,55.88 76.69,60.31C72.94,64.75 70,66.31 68.19,64.75Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="94.19dp"
android:height="123.31dp"
android:viewportWidth="94.19"
android:viewportHeight="123.31">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h94.19v123.31h-94.19z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M65.06,111.06C65.06,114.94 62.56,116.94 58.69,116.94L53.81,116.94C49.88,116.94 47.31,114.94 47.31,111.06L47.31,73.29C54.87,72.79 60.44,71.56 65.06,69.84Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M87.81,31.5C87.81,45.31 79.69,54.94 69.44,60.88C64.74,63.52 59.6,65.54 51.37,66.5C52.65,63.51 53.38,60.05 53.38,56.13C53.38,43.47 43,30.6 28.61,28.29C29.89,25.84 31.25,23.8 32.19,22.63C40.19,11.5 52.06,6.25 62.81,6.38C76.56,6.5 87.81,15.56 87.81,31.5ZM70.56,20.81L61.38,28.56C60.06,29.63 59.94,31.5 61,32.81C62.06,34.13 63.94,34.25 65.19,33.19L74.44,25.44C75.75,24.38 75.81,22.5 74.75,21.25C73.75,19.94 71.81,19.75 70.56,20.81Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
<path
android:pathData="M29.06,74.25C38.81,74.25 47.38,68.25 47.38,56.13C47.38,45.31 37.19,33.88 23.94,33.88C12.75,33.88 6.31,42.75 6.31,51.81C6.31,65.38 17.88,74.25 29.06,74.25ZM26,64.75C24.19,66.31 21.25,64.75 17.5,60.31C13.88,55.88 12.94,52.81 14.69,51.31C16.56,49.69 19.44,51.19 23.19,55.69C26.75,60.25 27.75,63.25 26,64.75Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,13 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="6dp"
android:useLevel="true">
<solid android:color="#00D85B" />
<corners android:radius="10dp" />
</shape>
</rotate>

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:fillColor="@android:color/white"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
</vector>

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88.06dp"
android:height="140.66dp"
android:viewportWidth="88.06"
android:viewportHeight="140.66">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h88.06v140.66h-88.06z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M0,77.55C0,79.99 1.88,81.8 4.5,81.8L39.81,81.8L21.19,132.42C18.75,138.86 25.44,142.3 29.63,137.05L86.44,66.05C87.5,64.74 88.06,63.49 88.06,62.05C88.06,59.67 86.19,57.8 83.56,57.8L48.25,57.8L66.88,7.17C69.31,0.74 62.63,-2.7 58.44,2.61L1.63,73.55C0.56,74.92 0,76.17 0,77.55Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,9 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000"/>
<corners android:radius="56dp"/>
<padding android:left="4dp" android:top="4dp" android:right="4dp" android:bottom="4dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="#0f4524" />
</shape>
</rotate>

View File

@@ -0,0 +1,11 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp" >
<solid android:color="#1ceb72" />
</shape>
</rotate>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid
android:color="#902E2E2E">
</solid>
<padding
android:left="1dp"
android:top="1dp"
android:right="1dp"
android:bottom="1dp" />
<corners
android:radius="5000dp">
</corners>
</shape>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid
android:color="@color/popup_background">
</solid>
<padding
android:bottom="56dp"
android:top="16dp">
</padding>
<corners
android:radius="56dp">
</corners>
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="6dp">
<solid android:color="#49474E" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80.31dp"
android:height="132.44dp"
android:viewportWidth="80.31"
android:viewportHeight="132.44">
<path
android:fillColor="#FF000000"
android:pathData="M0,0h80.31v132.44h-80.31z"
android:strokeAlpha="0"
android:fillAlpha="0"/>
<path
android:pathData="M12.44,114.31C11.06,114.31 10.06,113.31 10.06,111.94L10.06,12.44C10.06,11 11,10.06 12.44,10.06L67.88,10.06C69.31,10.06 70.25,11 70.25,12.44L70.25,111.94C70.25,113.31 69.31,114.31 67.88,114.31Z"
android:fillColor="#ffffff"
android:fillAlpha="0.2125"/>
<path
android:pathData="M7.75,132.31L72.56,132.31C77.13,132.31 80.31,129.13 80.31,124.56L80.31,7.75C80.31,3.19 77.13,0 72.56,0L7.75,0C3.19,0 0,3.19 0,7.75L0,124.56C0,129.13 3.25,132.31 7.75,132.31ZM12.44,114.31C11.06,114.31 10.06,113.31 10.06,111.94L10.06,12.44C10.06,11 11,10.06 12.44,10.06L67.88,10.06C69.31,10.06 70.25,11 70.25,12.44L70.25,111.94C70.25,113.31 69.31,114.31 67.88,114.31Z"
android:fillColor="#ffffff"
android:fillAlpha="0.85"/>
</vector>

View File

@@ -0,0 +1,9 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<padding android:bottom="0dp" android:left="0dp" android:right="0dp" android:top="0dp" />
<solid android:color="#222222" />
<corners android:radius="32dp" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#2C2A2F" />
<corners android:topLeftRadius="4dp" android:topRightRadius="24dp" android:bottomLeftRadius="4dp" android:bottomRightRadius="24dp" />
<padding android:bottom="8dp" android:left="8dp" android:right="8dp" android:top="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,9 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#2C2A2F" />
<corners android:radius="4dp" />
<padding android:bottom="8dp" android:left="8dp" android:right="8dp" android:top="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,9 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#2C2A2F" />
<corners android:topLeftRadius="24dp" android:topRightRadius="4dp" android:bottomLeftRadius="24dp" android:bottomRightRadius="4dp" />
<padding android:bottom="8dp" android:left="8dp" android:right="8dp" android:top="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,16 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3D3B40" />
<corners android:topLeftRadius="4dp" android:topRightRadius="24dp" android:bottomLeftRadius="4dp" android:bottomRightRadius="24dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#49474E" />
<corners android:topLeftRadius="4dp" android:topRightRadius="24dp" android:bottomLeftRadius="4dp" android:bottomRightRadius="24dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,16 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3D3B40" />
<corners android:radius="4dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#49474E" />
<corners android:radius="4dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,16 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3D3B40" />
<corners android:topLeftRadius="24dp" android:topRightRadius="4dp" android:bottomLeftRadius="24dp" android:bottomRightRadius="4dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#49474E" />
<corners android:topLeftRadius="24dp" android:topRightRadius="4dp" android:bottomLeftRadius="24dp" android:bottomRightRadius="4dp" />
<padding android:left="8dp" android:top="8dp" android:right="8dp" android:bottom="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,268 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.ALN.AppWidget.Container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:padding="0dp"
android:id="@+id/battery_widget"
android:theme="@style/Theme.ALN.AppWidgetContainer"
android:background="@drawable/widget_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:orientation="horizontal"
android:layout_margin="0dp"
android:id="@android:id/background"
android:layout_gravity="center">
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:gravity="center"
android:id="@+id/phone_battery_widget_container"
android:orientation="vertical">
<FrameLayout
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_margin="0dp"
android:padding="6dp"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/phone_battery_progress_background"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/progress_bar_background" />
<ProgressBar
android:id="@+id/phone_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/circular_progress_bar" />
<ImageView
android:id="@+id/phone_charging_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="top|center_horizontal"
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
android:tint="@color/white"
tools:ignore="HardcodedText" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/smartphone"
android:tint="@color/white"
android:layout_gravity="center"
android:importantForAccessibility="no"
tools:ignore="HardcodedText" />
</FrameLayout>
<TextView
android:id="@+id/phone_battery_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textColor="@color/white"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:textFontWeight="300"
android:text="Phone"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_margin="0dp"
android:padding="6dp"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/left_battery_progress_background"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/progress_bar_background" />
<ProgressBar
android:id="@+id/left_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/circular_progress_bar" />
<ImageView
android:id="@+id/left_charging_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="top|center_horizontal"
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
android:tint="@color/white"
tools:ignore="HardcodedText" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/airpods_pro_left_notification"
android:tint="@color/white"
android:layout_gravity="center"
android:importantForAccessibility="no"
tools:ignore="HardcodedText" />
</FrameLayout>
<TextView
android:id="@+id/left_battery_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textColor="@color/white"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:textFontWeight="300"
android:text="Left"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_margin="0dp"
android:padding="6dp"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/right_battery_progress_background"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/progress_bar_background" />
<ProgressBar
android:id="@+id/right_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progressDrawable="@drawable/circular_progress_bar" />
<ImageView
android:id="@+id/right_charging_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_gravity="top|center_horizontal"
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
android:tint="@color/white"
tools:ignore="HardcodedText" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:importantForAccessibility="no"
android:src="@drawable/airpods_pro_right_notification"
android:tint="@color/white"
android:layout_gravity="center"
tools:ignore="HardcodedText" />
</FrameLayout>
<TextView
android:id="@+id/right_battery_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textFontWeight="300"
android:textColor="@color/white"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="Right"
tools:ignore="HardcodedText" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<FrameLayout
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_margin="0dp"
android:padding="6dp"
android:layout_gravity="center">
<ProgressBar
android:id="@+id/case_battery_progress_background"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/progress_bar_background" />
<ProgressBar
android:id="@+id/case_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="false"
android:max="100"
android:progressDrawable="@drawable/circular_progress_bar" />
<ImageView
android:id="@+id/case_charging_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:importantForAccessibility="no"
android:layout_gravity="top|center_horizontal"
android:src="@drawable/ic_power"
android:visibility="gone"
android:tint="@color/white"
tools:ignore="HardcodedText" />
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:importantForAccessibility="no"
android:src="@drawable/airpods_pro_case_notification"
android:tint="@color/white"
android:layout_gravity="center"
tools:ignore="HardcodedText" />
</FrameLayout>
<TextView
android:id="@+id/case_battery_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textColor="@color/white"
android:gravity="center"
android:fontFamily="@font/sf_pro"
android:textFontWeight="300"
android:text="Case"
tools:ignore="HardcodedText" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/island_window_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:layout_weight="0.95"
android:background="@drawable/island_background"
android:elevation="4dp"
android:gravity="center"
android:minHeight="115dp"
android:orientation="horizontal"
android:outlineAmbientShadowColor="#4EFFFFFF"
android:outlineSpotShadowColor="#4EFFFFFF"
android:padding="8dp">
<VideoView
android:id="@+id/island_video_view"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="0dp"
android:layout_weight="1"
android:gravity="bottom"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/island_connected_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:textSize="16sp" />
<TextView
android:id="@+id/island_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="AirPods Pro"
android:textColor="@color/white"
android:textSize="24sp"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
tools:ignore="HardcodedText" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center">
<ProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/island_battery_background" />
<ProgressBar
android:id="@+id/island_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/island_battery_progress" />
<TextView
android:id="@+id/island_battery_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="50%"
android:textColor="#1ceb72"
android:textSize="16sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,140 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.ALN.AppWidget.Container"
android:id="@+id/noise_control_widget"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:theme="@style/Theme.ALN.AppWidgetContainer">
<LinearLayout
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/widget_off_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_start"
android:clickable="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:src="@drawable/noise_cancellation"
android:tint="@color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:shadowColor="@color/black"
android:shadowRadius="12"
android:text="@string/off"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_transparency_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp"
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_middle"
android:clickable="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:src="@drawable/transparency"
android:tint="@color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:shadowColor="@color/black"
android:shadowRadius="12"
android:text="@string/transparency"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_adaptive_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_middle"
android:clickable="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:src="@drawable/adaptive"
android:textSize="12sp"
android:tint="@color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:shadowColor="@color/black"
android:shadowRadius="12"
android:text="@string/adaptive"
android:textColor="@color/white"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/widget_anc_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginVertical="12dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="12dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_end"
android:clickable="true"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:src="@drawable/noise_cancellation"
android:tint="@color/white" />
<TextView
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:shadowColor="@color/black"
android:shadowRadius="12"
android:text="@string/noise_cancellation"
android:textColor="@color/white"
android:textSize="12sp"
tools:ignore="NestedWeights" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,13 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/notification_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
/>
</LinearLayout>

View File

@@ -0,0 +1,49 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/notification_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="?android:attr/textColorPrimary"
android:fontFamily="@font/sf_pro" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="8dp"
android:gravity="center">
<TextView
android:id="@+id/left_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:textSize="15sp"
android:paddingEnd="16dp"
android:layout_weight="1"
tools:ignore="RtlSymmetry" />
<TextView
android:id="@+id/right_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:textSize="15sp"
android:layout_weight="1"
android:paddingEnd="16dp"
tools:ignore="RtlSymmetry" />
<TextView
android:id="@+id/case_battery_notification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fontFamily="@font/sf_pro"
android:layout_weight="1"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16.dp"
android:id="@+id/linear_layout"
android:orientation="vertical"
android:background="@drawable/popup_shape">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:id="@+id/constraint_layout"
android:paddingBottom="48dp"
android:orientation="horizontal">
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="Kavish's AirPods Pro"
android:textColor="@color/popup_text"
android:textSize="28sp"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="HardcodedText" />
<ImageButton
android:id="@+id/close_button"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:background="@drawable/popup_button_shape"
android:contentDescription="Close Button"
android:src="@drawable/close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="HardcodedText" />
</androidx.constraintlayout.widget.ConstraintLayout>
<VideoView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/video"
android:contentDescription="AirPods"
android:src="@raw/connected"
tools:ignore="HardcodedText" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<!-- Left Half -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:textAlignment="center"
android:layout_marginTop="16dp"
android:fontFamily="@font/sf_pro"
android:text=""
android:textColor="@color/popup_text"
android:textSize="20sp"
android:id="@+id/left_battery"
android:gravity="center"
tools:ignore="NestedWeights" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:textAlignment="center"
android:layout_marginTop="16dp"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text=""
android:id="@+id/right_battery"
android:textColor="@color/popup_text"
android:textSize="20sp" />
</LinearLayout>
<!-- Right Half -->
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:textAlignment="center"
android:id="@+id/case_battery"
android:layout_marginTop="16dp"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text=""
android:textColor="@color/popup_text"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
-->
<style name="Theme.ALN.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="popup_background">#1C1B1E</color>
<color name="popup_text">@color/white</color>
<color name="widget_background">#1C1B1E</color>
<color name="widget_text">@color/white</color>
</resources>

View File

@@ -0,0 +1,14 @@
<resources>
<style name="Widget.ALN.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
</style>
<style name="Widget.ALN.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
</resources>

View File

@@ -0,0 +1,16 @@
<resources>
<style name="Widget.ALN.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
<item name="android:clipToOutline">true</item>
</style>
<style name="Widget.ALN.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
<item name="android:clipToOutline">true</item>
</style>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
and @android:dimen/system_app_widget_internal_padding requires API level 31
-->
<style name="Theme.ALN.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetPadding" format="dimension" />
<attr name="appWidgetInnerRadius" format="dimension" />
<attr name="appWidgetRadius" format="dimension" />
</declare-styleable>
</resources>

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