mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-29 14:20:48 +00:00
Compare commits
477 Commits
v0.0.1
...
multi-devi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b6e2a389 | ||
|
|
9b950e13d6 | ||
|
|
55768beb7c | ||
|
|
cea09b208a | ||
|
|
02edb51e41 | ||
|
|
10fc96dc94 | ||
|
|
1a2f5138a9 | ||
|
|
ee9de99204 | ||
|
|
c83ffca546 | ||
|
|
814eba8ed6 | ||
|
|
a9b78efd80 | ||
|
|
942ff82382 | ||
|
|
4a135fa463 | ||
|
|
39a64ec6f2 | ||
|
|
b7cc27f4d3 | ||
|
|
0e0af35103 | ||
|
|
993ba1ba08 | ||
|
|
3a9c118353 | ||
|
|
37313fbb1c | ||
|
|
d9469c2d62 | ||
|
|
b799cd1710 | ||
|
|
c7dc545ed4 | ||
|
|
342745ee2e | ||
|
|
8b49440d6b | ||
|
|
993f022087 | ||
|
|
650b128d5d | ||
|
|
395feabb13 | ||
|
|
6914dabe59 | ||
|
|
78ae31c898 | ||
|
|
b43e5f7526 | ||
|
|
9d60dc3682 | ||
|
|
c2ebbef14b | ||
|
|
3a388da48e | ||
|
|
bdb93efec6 | ||
|
|
504e70371b | ||
|
|
48b715af68 | ||
|
|
5ec300aad8 | ||
|
|
e158ba1b27 | ||
|
|
147e511659 | ||
|
|
e9da7a2a50 | ||
|
|
1076218ccc | ||
|
|
55cb69f880 | ||
|
|
5bc1dd2e1d | ||
|
|
1152f45a6c | ||
|
|
3f582b8fcf | ||
|
|
08738a1293 | ||
|
|
8dc7a97c43 | ||
|
|
d9795c4d28 | ||
|
|
56307c98e3 | ||
|
|
ab55096051 | ||
|
|
86a6a28dc1 | ||
|
|
7e5ee6726f | ||
|
|
5f08edd49c | ||
|
|
29a35ceebe | ||
|
|
b732c66962 | ||
|
|
173e06c5e7 | ||
|
|
26de42243f | ||
|
|
8760757b76 | ||
|
|
4bc76de750 | ||
|
|
4751f70579 | ||
|
|
ce229bec6e | ||
|
|
fe69082e11 | ||
|
|
3ace0e1831 | ||
|
|
ecfdc05dbf | ||
|
|
5aeb47b835 | ||
|
|
3cca786cf9 | ||
|
|
6fd3cc1eb0 | ||
|
|
bb69a74a8e | ||
|
|
71a1f834cb | ||
|
|
63baa153da | ||
|
|
5eff5b9d77 | ||
|
|
b5103a28e7 | ||
|
|
3699ee6bee | ||
|
|
032b94e3ae | ||
|
|
5c9beeb26d | ||
|
|
65d074efe0 | ||
|
|
93328d281e | ||
|
|
f5742618c7 | ||
|
|
792629acb9 | ||
|
|
5bef8c384e | ||
|
|
9e6d97198b | ||
|
|
c53356f77e | ||
|
|
fa00620b5b | ||
|
|
aecbb066b5 | ||
|
|
0e9aadd672 | ||
|
|
df9f443173 | ||
|
|
d1bf5407c9 | ||
|
|
4ee9b2732f | ||
|
|
86551be86b | ||
|
|
802c2e0220 | ||
|
|
f547cc13c0 | ||
|
|
11fa9180e2 | ||
|
|
73e55a02d6 | ||
|
|
325ef1e953 | ||
|
|
5e30531514 | ||
|
|
75fa80c17e | ||
|
|
eb1b633aff | ||
|
|
dde5d1e808 | ||
|
|
598bd3d7d8 | ||
|
|
46071f17d7 | ||
|
|
13ab2d1feb | ||
|
|
72a7637863 | ||
|
|
24686da1f3 | ||
|
|
d9359cd81a | ||
|
|
db563fa75f | ||
|
|
fb3c8c73a4 | ||
|
|
05c0a7c88b | ||
|
|
96ee2410e8 | ||
|
|
c0d915666b | ||
|
|
91ffaaa972 | ||
|
|
48ae249405 | ||
|
|
aaf82c9738 | ||
|
|
38d6f8ceae | ||
|
|
5754dbfb16 | ||
|
|
3b20540c34 | ||
|
|
595797c703 | ||
|
|
2e782ba051 | ||
|
|
3023c706bf | ||
|
|
0d582d890b | ||
|
|
9b907fdec4 | ||
|
|
43d703423a | ||
|
|
dcb25e2e52 | ||
|
|
31397f055e | ||
|
|
070713540a | ||
|
|
6574e52195 | ||
|
|
c4633d6871 | ||
|
|
5dc7e512ae | ||
|
|
b8e9765aff | ||
|
|
62aabe80c1 | ||
|
|
dc0b06a369 | ||
|
|
96baebee28 | ||
|
|
c05a37bcca | ||
|
|
8a69dbe173 | ||
|
|
b783b86b7a | ||
|
|
445c999208 | ||
|
|
96e63cf35e | ||
|
|
5472e09293 | ||
|
|
e852182b48 | ||
|
|
5eb13ace0c | ||
|
|
2b1fb5b71e | ||
|
|
c95a619465 | ||
|
|
c4bc47c48a | ||
|
|
6a026ebab0 | ||
|
|
f3ed3bbc70 | ||
|
|
5fe123f544 | ||
|
|
09e1aa1530 | ||
|
|
fd917d3fd0 | ||
|
|
84891a0bdf | ||
|
|
4b3cc92e56 | ||
|
|
b89d6d9dc2 | ||
|
|
6985aa4a7b | ||
|
|
9161f8b294 | ||
|
|
4c0381968f | ||
|
|
69439257ce | ||
|
|
810a3c90e4 | ||
|
|
0611509782 | ||
|
|
116f7dda92 | ||
|
|
51ca4c12d1 | ||
|
|
8e670c2481 | ||
|
|
aec9c7192e | ||
|
|
01432ce9c7 | ||
|
|
9baa3c9b60 | ||
|
|
364a6f4b64 | ||
|
|
9b96218fa9 | ||
|
|
98aef13395 | ||
|
|
42e0f48b8b | ||
|
|
4c73200f35 | ||
|
|
06de276dca | ||
|
|
7ffcd68ad9 | ||
|
|
295c49fdc6 | ||
|
|
b95962d722 | ||
|
|
45ed8a3a88 | ||
|
|
d381adaa09 | ||
|
|
58dfed97b3 | ||
|
|
48e2899564 | ||
|
|
7f7b439746 | ||
|
|
0b4030dd9f | ||
|
|
91675de891 | ||
|
|
53433809aa | ||
|
|
2bd0a3a20c | ||
|
|
7eafb7f013 | ||
|
|
96e7a81e46 | ||
|
|
a8f87f37f6 | ||
|
|
6376240ce0 | ||
|
|
a51efe35dc | ||
|
|
1571c6d300 | ||
|
|
924efc4faa | ||
|
|
893bcc97bc | ||
|
|
c320b4e27d | ||
|
|
ed0de4d9fa | ||
|
|
db763d7290 | ||
|
|
913e1a5aff | ||
|
|
ec1b0c47ca | ||
|
|
1c7bdf987c | ||
|
|
816992fd8a | ||
|
|
3aeff4d986 | ||
|
|
eacd862ef3 | ||
|
|
0846c3eb48 | ||
|
|
c2db0afdf1 | ||
|
|
f75419748b | ||
|
|
2bb2b0e697 | ||
|
|
22e511acf2 | ||
|
|
6ad36560a8 | ||
|
|
2fe9724da5 | ||
|
|
114c2c7210 | ||
|
|
fa63b9a774 | ||
|
|
c94295ae1c | ||
|
|
ecab6a9858 | ||
|
|
e3a7624d3e | ||
|
|
5182befac6 | ||
|
|
7a8b41dfa0 | ||
|
|
e384840bcc | ||
|
|
b1811770a3 | ||
|
|
42f91c4c46 | ||
|
|
33ba7a2f2d | ||
|
|
a41c24a21c | ||
|
|
acaf6f2edb | ||
|
|
e0624ce084 | ||
|
|
fb3f948250 | ||
|
|
1c745c8e08 | ||
|
|
e3dab8feb2 | ||
|
|
4e72f6573e | ||
|
|
543362da69 | ||
|
|
5a71d9630d | ||
|
|
09daaaedb2 | ||
|
|
3b1e91bf05 | ||
|
|
d79ce0237d | ||
|
|
1cf84bb4a5 | ||
|
|
05ff64f4b2 | ||
|
|
96c5bd089f | ||
|
|
0a13ba263b | ||
|
|
a206e04ba2 | ||
|
|
13340485b1 | ||
|
|
0463b7901b | ||
|
|
55ba67190d | ||
|
|
ce3c12f3b2 | ||
|
|
d004d12bb1 | ||
|
|
53960417b6 | ||
|
|
a6dbbd4f0c | ||
|
|
9ee0f733bc | ||
|
|
4d07cf4c16 | ||
|
|
c74054cc98 | ||
|
|
033e0be08d | ||
|
|
06f7b6bdb8 | ||
|
|
d5a96f8f5e | ||
|
|
654e4adad6 | ||
|
|
51f5a66a0e | ||
|
|
cd5b69e78c | ||
|
|
254e6c1fb4 | ||
|
|
d49a5df2e2 | ||
|
|
1cc09e203a | ||
|
|
ff3c33d1b2 | ||
|
|
256a42f2d2 | ||
|
|
705354430a | ||
|
|
bc8b1cc2b7 | ||
|
|
b6d18132fa | ||
|
|
446fde56d7 | ||
|
|
1babdad9a2 | ||
|
|
6e95d2fea3 | ||
|
|
56be361829 | ||
|
|
753f012d89 | ||
|
|
cb625d0889 | ||
|
|
9e40e6e3fd | ||
|
|
5b5a62f156 | ||
|
|
a6eb62bb77 | ||
|
|
4bb19a87c5 | ||
|
|
2e52eb3d7d | ||
|
|
be629c16ab | ||
|
|
28b0eac8a9 | ||
|
|
22d5ae60b6 | ||
|
|
84b548af14 | ||
|
|
1946857ca5 | ||
|
|
6fa8b5d611 | ||
|
|
e72b4a116e | ||
|
|
4438cdae6f | ||
|
|
7522292c8b | ||
|
|
1583f35a1e | ||
|
|
adfa11c660 | ||
|
|
55d06d2f65 | ||
|
|
96170fc9ce | ||
|
|
16586981a7 | ||
|
|
ac053a99b9 | ||
|
|
d9bf99de8b | ||
|
|
9107a43c39 | ||
|
|
6940f9c9e3 | ||
|
|
5908683540 | ||
|
|
918f6cfe6c | ||
|
|
9ab6a20de2 | ||
|
|
1d3dff175f | ||
|
|
f5df7e2bd3 | ||
|
|
6bfbe0904a | ||
|
|
c493a5b29f | ||
|
|
f11680aaad | ||
|
|
951180251e | ||
|
|
f074462489 | ||
|
|
bda572823a | ||
|
|
2195be741c | ||
|
|
471bf7ca3b | ||
|
|
8d6e8d7df7 | ||
|
|
ed26c4fec5 | ||
|
|
46d6cab930 | ||
|
|
5efbfa7ab7 | ||
|
|
6cb29e26d0 | ||
|
|
321a3bd3bf | ||
|
|
5cee33a354 | ||
|
|
055db073da | ||
|
|
c7ef31cba6 | ||
|
|
de53e840ed | ||
|
|
c84195aec8 | ||
|
|
43b98f7446 | ||
|
|
b6966f8c39 | ||
|
|
8b57a97a54 | ||
|
|
2d90898928 | ||
|
|
5dc589c8ff | ||
|
|
bbecb15f2e | ||
|
|
3679c6acf6 | ||
|
|
3f359e0315 | ||
|
|
189f19ecb3 | ||
|
|
35da57f0a5 | ||
|
|
ae188a72dc | ||
|
|
b607885fd8 | ||
|
|
58b3ee47ed | ||
|
|
75fd974473 | ||
|
|
b71471e1d5 | ||
|
|
40547919ce | ||
|
|
a5c65a324f | ||
|
|
2f9fbdc70e | ||
|
|
650a8d7e66 | ||
|
|
f67e5defcf | ||
|
|
66d7adf22c | ||
|
|
c48c6238ed | ||
|
|
8d67f54e40 | ||
|
|
ab67d7dc23 | ||
|
|
e1dc2c8925 | ||
|
|
93838d2d10 | ||
|
|
29acd203ae | ||
|
|
e81ae32b1f | ||
|
|
d01fe3938d | ||
|
|
5d364a662c | ||
|
|
a6d7bd704a | ||
|
|
2e87a3d66f | ||
|
|
938278b0b5 | ||
|
|
3a3074f592 | ||
|
|
7a06f3055c | ||
|
|
7cac2b037f | ||
|
|
4b3e1130e8 | ||
|
|
c9b04b24c0 | ||
|
|
e1c6677753 | ||
|
|
45d2cc302e | ||
|
|
7bd17635e5 | ||
|
|
39f0ae7106 | ||
|
|
dfc6dd611b | ||
|
|
888f1b3616 | ||
|
|
9a4d561f23 | ||
|
|
ba22d7c2a7 | ||
|
|
d77142c9a3 | ||
|
|
f265184abb | ||
|
|
122e3d6c42 | ||
|
|
f6d96b6a09 | ||
|
|
d63a2ac632 | ||
|
|
6564314ce0 | ||
|
|
b8054b2189 | ||
|
|
676c1329f3 | ||
|
|
6975d519cc | ||
|
|
c3e5f9e9c0 | ||
|
|
75d348bc95 | ||
|
|
c61b07d721 | ||
|
|
c93b5e2cdf | ||
|
|
2550aec66d | ||
|
|
4df1f6f1c5 | ||
|
|
ac2fcd384d | ||
|
|
6c6c6d9390 | ||
|
|
6c43a69542 | ||
|
|
c2ecc93d98 | ||
|
|
a51ac11b29 | ||
|
|
57b377d436 | ||
|
|
a72cbf129f | ||
|
|
d197a05a07 | ||
|
|
ea403728e2 | ||
|
|
383d8b2491 | ||
|
|
f367392824 | ||
|
|
f6ac82357f | ||
|
|
3461179389 | ||
|
|
a2019d7421 | ||
|
|
5c68e4fcec | ||
|
|
d7b6353bcf | ||
|
|
4ef74712b2 | ||
|
|
4c3d3e7286 | ||
|
|
19a286389b | ||
|
|
f5cc47b53c | ||
|
|
fc0475e2c0 | ||
|
|
5362108306 | ||
|
|
d3a7727fd3 | ||
|
|
4426fe1f4f | ||
|
|
9838dbd921 | ||
|
|
fda343ca39 | ||
|
|
278dc44796 | ||
|
|
c6863a8d2c | ||
|
|
039a43d15d | ||
|
|
5995c174e1 | ||
|
|
fad2681869 | ||
|
|
ed726922af | ||
|
|
5d9f91af6e | ||
|
|
96c4966bc2 | ||
|
|
5dbfe69ed4 | ||
|
|
fb4611677e | ||
|
|
af59b70537 | ||
|
|
bd473ee589 | ||
|
|
baacc9f1e2 | ||
|
|
416ae1e974 | ||
|
|
2c2552a57e | ||
|
|
7ed8f9b09c | ||
|
|
140ef0869b | ||
|
|
d4e45b221a | ||
|
|
3b1f66370a | ||
|
|
130b83c91a | ||
|
|
c941d0d320 | ||
|
|
bf1ebd01e4 | ||
|
|
2b577d7a49 | ||
|
|
66eaa985c8 | ||
|
|
9fea483d51 | ||
|
|
55aca982a1 | ||
|
|
26303192e5 | ||
|
|
4ad653f064 | ||
|
|
8792e992da | ||
|
|
430e1d6c41 | ||
|
|
7d3b80292b | ||
|
|
f5032c5e8e | ||
|
|
22700b897f | ||
|
|
0f563a60d1 | ||
|
|
6774ca1bb5 | ||
|
|
5f8f1d2041 | ||
|
|
1155a7103d | ||
|
|
66c550bae5 | ||
|
|
34ace1fc6e | ||
|
|
ff6c72ffa6 | ||
|
|
1f3cd50f11 | ||
|
|
809818f60d | ||
|
|
7b03a7e9f0 | ||
|
|
95664ed4be | ||
|
|
214c7ac1c9 | ||
|
|
255edc5b08 | ||
|
|
5dfd962ffa | ||
|
|
f5c0a02291 | ||
|
|
4d8841205b | ||
|
|
1afc61513f | ||
|
|
3b4f887300 | ||
|
|
be02ab1830 | ||
|
|
405584f8eb | ||
|
|
b1249f27f5 | ||
|
|
ebdb1609f3 | ||
|
|
db8458be59 | ||
|
|
4374a81915 | ||
|
|
2679205dc3 | ||
|
|
0c1f9464ad | ||
|
|
7a246b3800 | ||
|
|
9fa8046886 | ||
|
|
2c64cf7cf7 | ||
|
|
c6ab7038cd | ||
|
|
8917753830 | ||
|
|
3fe268eb15 | ||
|
|
f12cf0c16f | ||
|
|
c6af6a147f | ||
|
|
71dffd1415 | ||
|
|
4a1d7df82d | ||
|
|
3e0de6f011 | ||
|
|
58de49d1b1 | ||
|
|
c360c21305 | ||
|
|
48e7c91282 | ||
|
|
61e4a4168c | ||
|
|
b6a35756d0 | ||
|
|
2c68c1cf18 | ||
|
|
6424a51e8a | ||
|
|
bef3cd9774 | ||
|
|
f4cd02831c | ||
|
|
745040be2b | ||
|
|
a8de72f190 |
20
.editorconfig
Normal file
20
.editorconfig
Normal 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
|
||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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.
|
||||
98
.github/workflows/ci-android.yml
vendored
Normal file
98
.github/workflows/ci-android.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
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
|
||||
custom_notes:
|
||||
description: 'Custom updates to add to What''s Changed section'
|
||||
required: false
|
||||
type: string
|
||||
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=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
- name: Rename .apk file
|
||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
||||
- name: Decode keystore file
|
||||
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
|
||||
- name: Install apksigner
|
||||
run: sudo apt-get update && sudo apt-get install -y apksigner
|
||||
- name: Sign APK
|
||||
run: |
|
||||
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
|
||||
- name: Verify APK
|
||||
run: apksigner verify "./$APK_NAME"
|
||||
- name: Fetch the latest non-nightly release tag
|
||||
id: fetch-tag
|
||||
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
|
||||
- name: Retrieve commits since the last release
|
||||
id: get-commits
|
||||
run: |
|
||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
||||
echo "::set-output name=commits::${COMMITS}"
|
||||
- name: Prepare release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
# Create a temporary file for release notes
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
# Process custom notes if they exist
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
||||
|
||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
||||
# Already formatted, use as is
|
||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
else
|
||||
# Add bullet point formatting
|
||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
- name: Zip root-module directory
|
||||
run: sh ./build-magisk-module.sh
|
||||
- name: Delete release if exist then create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
||||
36
.github/workflows/ci-linux.yml
vendored
Normal file
36
.github/workflows/ci-linux.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build LibrePods Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
|
||||
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Build project
|
||||
working-directory: linux
|
||||
run: |
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G Ninja
|
||||
ninja
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: librepods-linux
|
||||
path: linux/build/librepods
|
||||
473
.gitignore
vendored
473
.gitignore
vendored
@@ -1,7 +1,17 @@
|
||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
||||
wak.toml
|
||||
log.txt
|
||||
btl2capfix.zip
|
||||
root-module-manual
|
||||
.vscode
|
||||
testing.py
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
|
||||
# Android Template
|
||||
|
||||
|
||||
### Android ###
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
@@ -36,19 +46,284 @@ google-services.json
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Python Template
|
||||
### Android Patch ###
|
||||
gen-external-apklibs
|
||||
|
||||
# Replacement of .externalNativeBuild directories introduced
|
||||
# with Android Studio 3.5.
|
||||
|
||||
### C++ ###
|
||||
# Prerequisites
|
||||
*.d
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Compiled Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
### CLion ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### CLion Patch ###
|
||||
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||
|
||||
# *.iml
|
||||
# modules.xml
|
||||
# .idea/misc.xml
|
||||
# *.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||
.idea/**/sonarlint/
|
||||
|
||||
# SonarQube Plugin
|
||||
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||
.idea/**/sonarIssues.xml
|
||||
|
||||
# Markdown Navigator plugin
|
||||
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||
.idea/**/markdown-navigator.xml
|
||||
.idea/**/markdown-navigator-enh.xml
|
||||
.idea/**/markdown-navigator/
|
||||
|
||||
# Cache file creation bug
|
||||
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||
.idea/$CACHE_FILE$
|
||||
|
||||
# CodeStream plugin
|
||||
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||
.idea/codestream.xml
|
||||
|
||||
# Azure Toolkit for IntelliJ plugin
|
||||
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
|
||||
.idea/**/azureSettings.xml
|
||||
|
||||
### Kotlin ###
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### PyCharm ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
|
||||
# AWS User-specific
|
||||
|
||||
# Generated files
|
||||
|
||||
# Sensitive or high-churn files
|
||||
|
||||
# Gradle
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
|
||||
# Mongo Explorer plugin
|
||||
|
||||
# File-based project format
|
||||
|
||||
# IntelliJ
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
|
||||
# JIRA plugin
|
||||
|
||||
# Cursive Clojure plugin
|
||||
|
||||
# SonarLint plugin
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
|
||||
# Editor-based Rest Client
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
|
||||
### PyCharm Patch ###
|
||||
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||
|
||||
# *.iml
|
||||
# modules.xml
|
||||
# .idea/misc.xml
|
||||
# *.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||
|
||||
# SonarQube Plugin
|
||||
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||
|
||||
# Markdown Navigator plugin
|
||||
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||
|
||||
# Cache file creation bug
|
||||
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||
|
||||
# CodeStream plugin
|
||||
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||
|
||||
# Azure Toolkit for IntelliJ plugin
|
||||
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
@@ -96,7 +371,6 @@ cover/
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
@@ -146,10 +420,8 @@ ipython_config.py
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
@@ -200,3 +472,190 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### Qt ###
|
||||
# C++ objects and libs
|
||||
*.so.*
|
||||
|
||||
# Qt-es
|
||||
object_script.*.Release
|
||||
object_script.*.Debug
|
||||
*_plugin_import.cpp
|
||||
/.qmake.cache
|
||||
/.qmake.stash
|
||||
*.pro.user
|
||||
*.pro.user.*
|
||||
*.qbs.user
|
||||
*.qbs.user.*
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
moc_*.h
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
*.qmlc
|
||||
*.jsc
|
||||
Makefile*
|
||||
*build-*
|
||||
*.qm
|
||||
*.prl
|
||||
|
||||
# Qt unit tests
|
||||
target_wrapper.*
|
||||
|
||||
# QtCreator
|
||||
*.autosave
|
||||
|
||||
# QtCreator Qml
|
||||
*.qmlproject.user
|
||||
*.qmlproject.user.*
|
||||
|
||||
# QtCreator CMake
|
||||
CMakeLists.txt.user*
|
||||
|
||||
# QtCreator 4.8< compilation database
|
||||
compile_commands.json
|
||||
|
||||
# QtCreator local machine specific files for imported projects
|
||||
*creator.user*
|
||||
|
||||
*_qmlcache.qrc
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
### AndroidStudio ###
|
||||
# Covers files to be ignored for android development using Android Studio.
|
||||
|
||||
# Built application files
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle
|
||||
|
||||
# Signing files
|
||||
.signing/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
|
||||
# Android Studio
|
||||
/*/build/
|
||||
/*/local.properties
|
||||
/*/out
|
||||
/*/*/build
|
||||
/*/*/production
|
||||
.navigation/
|
||||
*.ipr
|
||||
*.swp
|
||||
|
||||
# Keystore files
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Android Patch
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
# NDK
|
||||
obj/
|
||||
|
||||
# IntelliJ IDEA
|
||||
/out/
|
||||
|
||||
# User-specific configurations
|
||||
.idea/caches/
|
||||
.idea/libraries/
|
||||
.idea/shelf/
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/.name
|
||||
.idea/compiler.xml
|
||||
.idea/copyright/profiles_settings.xml
|
||||
.idea/encodings.xml
|
||||
.idea/misc.xml
|
||||
.idea/modules.xml
|
||||
.idea/scopes/scope_settings.xml
|
||||
.idea/dictionaries
|
||||
.idea/vcs.xml
|
||||
.idea/jsLibraryMappings.xml
|
||||
.idea/datasources.xml
|
||||
.idea/dataSources.ids
|
||||
.idea/sqlDataSources.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/gradle.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Legacy Eclipse project files
|
||||
.classpath
|
||||
.project
|
||||
.cproject
|
||||
.settings/
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
|
||||
# Package Files #
|
||||
|
||||
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
|
||||
# JIRA plugin
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/mongoSettings.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
|
||||
### AndroidStudio Patch ###
|
||||
|
||||
!/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
||||
linux/.qmlls.ini
|
||||
|
||||
@@ -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
|
||||
@@ -134,7 +134,57 @@ AirPods send conversational awareness packets when the person wearing them start
|
||||
| 03 | Person Stopped Speaking; increase volume back to normal |
|
||||
| Intermediate values | Intermediate volume levels |
|
||||
| 08/09 | Normal Volume |
|
||||
### Reading Conversational Awareness State
|
||||
|
||||
After requesting notifications, the AirPods send a packet indicating the current state of Conversational Awareness (CA). This packet is only sent once after notifications are requested, not when the CA state is changed.
|
||||
|
||||
The packet format is:
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 09 00 28 [status] 00 00 00
|
||||
```
|
||||
|
||||
- `[status]` is a single byte at offset 7 (zero-based), immediately after the header.
|
||||
- `0x01` — Conversational Awareness is **enabled**
|
||||
- `0x02` — Conversational Awareness is **disabled**
|
||||
- Any other value — Unknown/undetermined state
|
||||
|
||||
**Example:**
|
||||
```plaintext
|
||||
04 00 04 00 09 00 28 01 00 00 00
|
||||
```
|
||||
Here, `01` at the 8th byte (offset 7) means CA is enabled.
|
||||
|
||||
## Metadata
|
||||
|
||||
This packet contains device information like name, model number, etc. The packet format is:
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 1d [strings...]
|
||||
```
|
||||
|
||||
The strings are null-terminated UTF-8 strings in the following order:
|
||||
|
||||
1. Bluetooth advertising name (varies in length)
|
||||
2. Model number
|
||||
3. Manufacturer
|
||||
4. Serial number
|
||||
5. Firmware version
|
||||
6. Firmware version 2 (the exact same as before??)
|
||||
7. Software version (1.0.0 why would we need it?)
|
||||
8. App identifier (com.apple.accessory.updater.app.71 what?)
|
||||
9. Serial number 1
|
||||
10. Serial number 2
|
||||
11. Unknown numeric value
|
||||
12. Encrypted data
|
||||
13. Additional encrypted data
|
||||
|
||||
Example packet:
|
||||
```plaintext
|
||||
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
|
||||
|
||||
The packet contains device identification and version information followed by some encrypted data whose format is not known.
|
||||
```
|
||||
|
||||
# Writing to the AirPods
|
||||
|
||||
@@ -211,34 +261,49 @@ 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
|
||||
|
||||
```
|
||||
12 18 00 [enabled]
|
||||
<left bud>
|
||||
[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8]
|
||||
[Amplification]
|
||||
[Tone]
|
||||
[Conversation Boost]
|
||||
[Ambient Noise Reduction]
|
||||
<repeat for right bud>
|
||||
```
|
||||
|
||||
|
||||
All values are formatted as IEEE 754 floats in little endian order.
|
||||
| Data | Type | Range |
|
||||
|-------------------------|---------------|-------|
|
||||
| Enabled | IEEE754 Float | 0/1 |
|
||||
| EQ | IEEE754 Float | 0-100 |
|
||||
| Amplification | IEEE754 Float | 0-2 |
|
||||
| Tone | IEEE754 Float | 0-2 |
|
||||
| Conversation Boost | IEEE754 Float | 0/1 |
|
||||
| Ambient Noise Reduction | IEEE754 Float | 0-1 |
|
||||
| Ambient Noise Reduction | IEEE754 Float | 0-1 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Also send the [Headphone Accomodation](#headphone-accomodation) after this.
|
||||
|
||||
# Miscellaneous/Unknown
|
||||
|
||||
## Configure Stem Long Press
|
||||
|
||||
@@ -310,20 +375,48 @@ 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}$*
|
||||
|
||||
## Request something (Probably Head Positions)
|
||||
# Head Tracking
|
||||
|
||||
## Start Tracking
|
||||
|
||||
This packet initiates head tracking. When sent, the AirPods begin streaming head tracking data (e.g. orientation and acceleration) for live plotting and analysis.
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 11 00 08 7C 10 02 42 0B 08 4E 10 02 1A 05 01 40 9C 00 00
|
||||
04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00
|
||||
```
|
||||
|
||||
Example packet
|
||||
## Stop Tracking
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 43 00 08 ec 07 10 01 1a 3c 0e 00 01 90 95 5d af 86 19 00 00 03 04 43 94 04 9e 6b 01 00 00 00 d5 a2 06 13 eb 13 03 00 f0 ff 01 00 67 83 67 83 67 83 fe ff fd ff 07 00 b3 01 9c 03 65 00 48 74 2c 37 fd 1e 00 00
|
||||
```
|
||||
|
||||
## Stop whatever was requested
|
||||
This packet stops the head tracking data stream.
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00
|
||||
```
|
||||
## Received Head Tracking Sensor Data
|
||||
|
||||
Once tracking is active, the AirPods stream sensor packets with the following common structure:
|
||||
|
||||
| Field | Offset | Length (bytes) |
|
||||
|--------------------------|--------|----------------|
|
||||
| orientation 1 | 43 | 2 |
|
||||
| orientation 2 | 45 | 2 |
|
||||
| orientation 3 | 47 | 2 |
|
||||
| Horizontal Acceleration | 51 | 2 |
|
||||
| Vertical Acceleration | 53 | 2 |
|
||||
|
||||
# LICENSE
|
||||
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
@@ -0,0 +1,2 @@
|
||||
## LibrePods root module changelog
|
||||
_[See here](https://github.com/kavishdevar/librepods/releases)_
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal 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
70
CONTRIBUTING.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Welcome to LibrePods contributing guide <!-- omit in toc -->
|
||||
|
||||
Thank you for considering a contribution to LibrePods! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
|
||||
|
||||
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful.
|
||||
|
||||
This guide provides an overview of the contribution workflow, from opening an issue to creating and reviewing a pull request (PR).
|
||||
|
||||
## New contributor guide
|
||||
|
||||
To get an overview of the project, read the [README](./README.md). Here are some resources to help you get started with open-source contributions:
|
||||
|
||||
- [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
|
||||
- [Set up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
|
||||
- [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow)
|
||||
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
|
||||
|
||||
## Getting started
|
||||
|
||||
To navigate our codebase with confidence, see the [README](./README.md) for setup instructions and usage details. We accept various types of contributions, which don’t always require writing code (like translations).
|
||||
|
||||
To develop for the Android App, Android Studio is the preferred IDE. And you can use any IDE for the linux program, it is just python!
|
||||
|
||||
### Issues
|
||||
|
||||
#### Create a new issue
|
||||
|
||||
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/librepods/issues). If no relevant issue exists, open a new one and fill in the details.
|
||||
|
||||
#### Solve an issue
|
||||
|
||||
Browse our [issues list](https://github.com/kavishdevar/librepods/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution.
|
||||
|
||||
### Make Changes
|
||||
|
||||
#### Make changes locally
|
||||
|
||||
1. Fork the repository and clone it to your local environment.
|
||||
```
|
||||
git clone https://github.com/kavishdevar/librepods.git
|
||||
cd AirPods-Like-Normal
|
||||
```
|
||||
2. Create a working branch to start your changes.
|
||||
```
|
||||
git checkout -b your-feature-branch
|
||||
```
|
||||
3. Make your changes, following the existing style and structure.
|
||||
|
||||
4. Test your changes to ensure they work as expected and do not introduce new issues.
|
||||
|
||||
### Commit your changes
|
||||
|
||||
Commit your changes with a descriptive message.
|
||||
|
||||
### Pull Request
|
||||
|
||||
When your changes are ready, create a pull request (PR):
|
||||
- Fill out the PR template to help reviewers understand your changes.
|
||||
- If your PR is related to an issue, don’t forget to [link your PR to it](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Enable the checkbox to allow maintainers to edit your PR, so any required changes can be merged easily.
|
||||
|
||||
Once your PR is open, a team member will review it. They may ask questions or request additional information.
|
||||
|
||||
- If changes are requested, apply them in your fork and commit them to the PR branch.
|
||||
- Mark conversations as resolved as you apply feedback.
|
||||
- For merge conflicts, follow this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to resolve them.
|
||||
|
||||
### Your PR is merged!
|
||||
|
||||
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.
|
||||
1
FUNDING.yml
Normal file
1
FUNDING.yml
Normal file
@@ -0,0 +1 @@
|
||||
github: kavishdevar
|
||||
161
LICENSE
161
LICENSE
@@ -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/>.
|
||||
164
Proximity Pairing Message.md
Normal file
164
Proximity Pairing Message.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Bluetooth Low Energy (BLE) - Apple Proximity Pairing Message
|
||||
|
||||
This document describes how the AirPods BLE "Proximity Pairing Message" is parsed and interpreted in the application. This message is broadcast by Apple devices (such as AirPods) and contains key information about the device's state, battery, and other properties.
|
||||
|
||||
## Overview
|
||||
|
||||
When scanning for BLE devices, the application looks for manufacturer data with Apple's ID (`0x004C`). If the data starts with `0x07`, it is identified as a Proximity Pairing Message. The message contains various fields, each representing a specific property of the AirPods.
|
||||
|
||||
## Proximity Pairing Message Structure
|
||||
|
||||
| Byte Index | Field Name | Description | Example Value(s) |
|
||||
|------------|-------------------------|---------------------------------------------------------|--------------------------|
|
||||
| 0 | Prefix | Message type (should be `0x07` for proximity pairing) | `0x07` |
|
||||
| 1 | Length | Length of the message | `0x12` |
|
||||
| 2 | Pairing Mode | `0x01` = Paired, `0x00` = Pairing mode | `0x01`, `0x00` |
|
||||
| 3-4 | Device Model | Big-endian: [3]=high, [4]=low | `0x0E20` (AirPods Pro) |
|
||||
| 5 | Status | Bitfield, see below | `0x62` |
|
||||
| 6 | Pods Battery Byte | Nibbles for left/right pod battery | `0xA7` |
|
||||
| 7 | Flags & Case Battery | Upper nibble: case battery, lower: flags | `0xB3` |
|
||||
| 8 | Lid Indicator | Bits for lid state and open counter | `0x09` |
|
||||
| 9 | Device Color | Color code | `0x02` |
|
||||
| 10 | Connection State | Enum, see below | `0x04` |
|
||||
| 11-26 | Encrypted Payload | 16 bytes, not parsed | |
|
||||
|
||||
## Field Details
|
||||
|
||||
### Device Model
|
||||
|
||||
| Value (hex) | Model Name |
|
||||
|-------------|--------------------------|
|
||||
| 0x0220 | AirPods 1st Gen |
|
||||
| 0x0F20 | AirPods 2nd Gen |
|
||||
| 0x1320 | AirPods 3rd Gen |
|
||||
| 0x1920 | AirPods 4th Gen |
|
||||
| 0x1B20 | AirPods 4th Gen (ANC) |
|
||||
| 0x0A20 | AirPods Max |
|
||||
| 0x1F20 | AirPods Max (USB-C) |
|
||||
| 0x0E20 | AirPods Pro |
|
||||
| 0x1420 | AirPods Pro 2nd Gen |
|
||||
| 0x2420 | AirPods Pro 2nd Gen (USB-C) |
|
||||
|
||||
### Status Byte (Bitfield)
|
||||
|
||||
| Bit | Meaning | Value if Set |
|
||||
|-----|--------------------------------|-------------|
|
||||
| 0 | Right Pod In Ear (XOR logic) | true |
|
||||
| 1 | Right Pod In Ear (XOR logic) | true |
|
||||
| 2 | Both Pods In Case | true |
|
||||
| 3 | Left Pod In Ear (XOR logic) | true |
|
||||
| 4 | One Pod In Case | true |
|
||||
| 5 | Primary Pod (1=Left, 0=Right) | true/false |
|
||||
| 6 | This Pod In Case | true |
|
||||
|
||||
### Ear Detection Logic
|
||||
|
||||
The in-ear detection uses XOR logic based on:
|
||||
- Whether the right pod is primary (`areValuesFlipped`)
|
||||
- Whether this pod is in the case (`isThisPodInTheCase`)
|
||||
|
||||
```cpp
|
||||
bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase;
|
||||
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
|
||||
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
|
||||
```
|
||||
|
||||
### Primary Pod
|
||||
|
||||
Determined by bit 5 of the status byte:
|
||||
- `1` = Left pod is primary
|
||||
- `0` = Right pod is primary
|
||||
|
||||
This affects:
|
||||
1. Battery level interpretation (which nibble corresponds to which pod)
|
||||
2. Microphone assignment
|
||||
3. Ear detection logic
|
||||
|
||||
### Microphone Status
|
||||
|
||||
The active microphone is determined by:
|
||||
```cpp
|
||||
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
```
|
||||
|
||||
### Pods Battery Byte
|
||||
|
||||
- Upper nibble: one pod battery (depends on primary)
|
||||
- Lower nibble: other pod battery
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|----------------|
|
||||
| 0x0-0x9 | 0-90% (x10) |
|
||||
| 0xA-0xE | 100% |
|
||||
| 0xF | Not available|
|
||||
|
||||
### Flags & Case Battery Byte
|
||||
|
||||
- Upper nibble: case battery (same encoding as pods)
|
||||
- Lower nibble: flags
|
||||
|
||||
#### Flags (Lower Nibble)
|
||||
|
||||
| Bit | Meaning |
|
||||
|-----|--------------------------|
|
||||
| 0 | Right Pod Charging (XOR) |
|
||||
| 1 | Left Pod Charging (XOR) |
|
||||
| 2 | Case Charging |
|
||||
|
||||
### Lid Indicator
|
||||
|
||||
| Bits | Meaning |
|
||||
|------|------------------------|
|
||||
| 0-2 | Lid Open Counter |
|
||||
| 3 | Lid State (0=Open, 1=Closed) |
|
||||
|
||||
### Device Color
|
||||
|
||||
| Value | Color |
|
||||
|-------|-------------|
|
||||
| 0x00 | White |
|
||||
| 0x01 | Black |
|
||||
| 0x02 | Red |
|
||||
| 0x03 | Blue |
|
||||
| 0x04 | Pink |
|
||||
| 0x05 | Gray |
|
||||
| 0x06 | Silver |
|
||||
| 0x07 | Gold |
|
||||
| 0x08 | Rose Gold |
|
||||
| 0x09 | Space Gray |
|
||||
| 0x0A | Dark Blue |
|
||||
| 0x0B | Light Blue |
|
||||
| 0x0C | Yellow |
|
||||
| 0x0D+ | Unknown |
|
||||
|
||||
### Connection State
|
||||
|
||||
| Value | State |
|
||||
|-------|--------------|
|
||||
| 0x00 | Disconnected |
|
||||
| 0x04 | Idle |
|
||||
| 0x05 | Music |
|
||||
| 0x06 | Call |
|
||||
| 0x07 | Ringing |
|
||||
| 0x09 | Hanging Up |
|
||||
| 0xFF | Unknown |
|
||||
|
||||
## Example Message
|
||||
|
||||
| Byte Index | Example Value | Description |
|
||||
|------------|--------------|----------------------------|
|
||||
| 0 | 0x07 | Proximity Pairing Message |
|
||||
| 1 | 0x12 | Length |
|
||||
| 2 | 0x01 | Paired |
|
||||
| 3-4 | 0x0E 0x20 | AirPods Pro |
|
||||
| 5 | 0x62 | Status |
|
||||
| 6 | 0xA7 | Pods Battery |
|
||||
| 7 | 0xB3 | Flags & Case Battery |
|
||||
| 8 | 0x09 | Lid Indicator |
|
||||
| 9 | 0x02 | Device Color |
|
||||
| 10 | 0x04 | Connection State (Idle) |
|
||||
|
||||
---
|
||||
|
||||
For further details, see [`BleManager`](linux/ble/blemanager.cpp) and [`BleScanner`](linux/ble/blescanner.cpp).
|
||||
177
README.md
177
README.md
@@ -1,56 +1,165 @@
|
||||
# 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)
|
||||

|
||||
|
||||
## Currently supported device(s)
|
||||
- AirPods Pro 2
|
||||
|
||||
## Implemented Features
|
||||
|
||||
| Feature | Linux | Android |
|
||||
| --- | --- | --- |
|
||||
| Ear Detection | ✅ | ✅ |
|
||||
| Conversational Awareness | ✅ | ✅ |
|
||||
| Setting Noise Control | ✅ | ✅ |
|
||||
| Battery Level | ✅ | ✅ |
|
||||
| Rename AirPods | ✅ | ❌ |
|
||||
| Adjust Adaptive Audio | ❌ | ✅ |
|
||||
[](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
|
||||
[](https://github.com/kavishdevar/librepods/releases/latest)
|
||||
[](https://github.com/kavishdevar/librepods/releases)
|
||||
[](https://github.com/kavishdevar/librepods/stargazers)
|
||||
[](https://github.com/kavishdevar/librepods/issues)
|
||||
[](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
|
||||
[](https://github.com/kavishdevar/librepods/graphs/contributors)
|
||||
|
||||
|
||||
## Linux
|
||||
Check out the README file in [linux](/linux) folder for more info.
|
||||
## What is LibrePods?
|
||||
|
||||
This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more.
|
||||
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
|
||||
|
||||

|
||||

|
||||
## Device Compatibility
|
||||
|
||||
## Android
|
||||
| Status | Device | Features |
|
||||
| ------ | --------------------- | ---------------------------------------------------------- |
|
||||
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
|
||||
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
|
||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
||||
|
||||
> Currently, there's a [bug on android](https://issuetracker.google.com/issues/371713238) that prevents this from working (psst, go upvote!)
|
||||
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with.
|
||||
|
||||
But once that's fixed, or you have fixed the issue using root, download the APK, and you're off!
|
||||
## Key Features
|
||||
|
||||
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:
|
||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||
- **Battery Status**: Accurate battery levels
|
||||
- **Head Gestures**: Answer calls just by nodding your head
|
||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
||||
- **Hearing Aid\***
|
||||
- **Customize Transparency Mode\***
|
||||
- **Multi-device connectivity\*** (upto 2 devices)
|
||||
- **Other customizations**:
|
||||
- Rename your AirPods
|
||||
- Customize long-press actions
|
||||
- Few accessibility features
|
||||
- And more!
|
||||
|
||||

|
||||

|
||||
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
|
||||
|
||||
> Quick Tile to toggle Conversational Awareness and to switch Noise Control modes, and Battery Widget (App and AndroidSystemIntelligence)/Notification coming soon!!
|
||||
## Platform Support
|
||||
|
||||
### Linux
|
||||
|
||||
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
|
||||
|
||||
- Battery monitoring
|
||||
- Automatic Ear detection
|
||||
- Conversational Awareness
|
||||
- Switching Noise Control modes
|
||||
- Device renaming
|
||||
|
||||
> [!NOTE]
|
||||
> Work in progress, but core functionality is stable and usable.
|
||||
|
||||
For installation and detailed info, see the [Linux README](/linux/README.md).
|
||||
|
||||
### Android
|
||||
|
||||
#### Screenshots
|
||||
|
||||
| | | |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
|
||||
here's a very unprofessional demo video
|
||||
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
#### Root Requirement
|
||||
|
||||
> [!CAUTION]
|
||||
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
|
||||
>
|
||||
> There are **no exceptions** to the root requirement until Google merges the fix.
|
||||
|
||||
## Bluetooth DID (Device Identification) Hook
|
||||
|
||||
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
|
||||
|
||||
### Multi-device Connectivity
|
||||
|
||||
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
|
||||
|
||||
### Accessibility Settings and Hearing Aid
|
||||
|
||||
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
||||
|
||||
The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
|
||||
|
||||
>[!NOTE]
|
||||
> To enable these features, enable App Settings -> `act as Apple Device`.
|
||||
> This only works if you use the Xposed method or patch the library yourself. The root module method does not support this feature currently.
|
||||
|
||||
#### Installation Methods
|
||||
|
||||
##### Method 1: Xposed Module (Recommended)
|
||||
This method is less intrusive and should be tried first:
|
||||
|
||||
1. Install LSPosed, or another Xposed provider on your rooted device
|
||||
2. Download the LibrePods app from the releases section, and install it.
|
||||
3. Enable the Xposed module for the bluetooth app in your Xposed manager.
|
||||
4. Disable unmount modules for the Bluetooth app if enabled.
|
||||
5. Follow the instructions in the app to set up the module.
|
||||
6. Open the app and connect your AirPods
|
||||
|
||||
##### Method 2: Root Module (Backup Option)
|
||||
If the Xposed method doesn't work for you:
|
||||
|
||||
1. Download the `btl2capfix.zip` module from the releases section
|
||||
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
|
||||
3. Disable Unmount modules for the Bluetooth aop if enabled.
|
||||
4. Reboot your device
|
||||
5. Connect your AirPods
|
||||
|
||||
##### Method 3: Patching it yourself
|
||||
If you prefer to patch the Bluetooth stack yourself, follow these steps:
|
||||
|
||||
1. Look for the library in use by running `lsof | grep libbluetooth`
|
||||
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
|
||||
3. Find the `l2c_fcr_chk_chan_modes` function in the library
|
||||
4. Patch the function to always return `1` (true)
|
||||
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
|
||||
6. Reboot your device
|
||||
|
||||
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
|
||||
|
||||
#### A few notes
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
|
||||
|
||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#kavishdevar/librepods&Date)
|
||||
|
||||
# License
|
||||
|
||||
AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
Copyright (C) 2024 Kavish Devar
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
|
||||
1
android/.gitignore
vendored
1
android/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
crowdin.yml
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
## Debug Screen
|
||||

|
||||
@@ -2,21 +2,20 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.kavishdevar.aln"
|
||||
compileSdk = 34
|
||||
namespace = "me.kavishdevar.librepods"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.aln"
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "0.2.0-beta.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -37,6 +36,18 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +64,26 @@ dependencies {
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.annotations)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.aboutlibraries)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
export{
|
||||
prettyPrint = true
|
||||
excludeFields = listOf("generated")
|
||||
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
||||
}
|
||||
}
|
||||
|
||||
BIN
android/app/libs/backdrop-debug.aar
Normal file
BIN
android/app/libs/backdrop-debug.aar
Normal file
Binary file not shown.
BIN
android/app/libs/backdrop-release.aar
Normal file
BIN
android/app/libs/backdrop-release.aar
Normal file
Binary file not shown.
BIN
android/app/libs/libxposed-api-100.aar
Normal file
BIN
android/app/libs/libxposed-api-100.aar
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "me.kavishdevar.aln",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 28
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package me.kavishdevar.aln
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("me.kavishdevar.aln", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -2,58 +2,151 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
<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" />
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<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">
|
||||
android:theme="@style/Theme.LibrePods"
|
||||
android:description="@string/app_description"
|
||||
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=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ALN">
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- <intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="librepods"
|
||||
android:host="add-magic-keys" />
|
||||
</intent-filter> -->
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".QuickSettingsDialogActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TransparentDialog"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".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>
|
||||
<service
|
||||
android:name=".services.AppListenerService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/app_listener_service_config" />
|
||||
</service>
|
||||
<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>
|
||||
|
||||
<!-- <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>-->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
</manifest>
|
||||
|
||||
12
android/app/src/main/cpp/CMakeLists.txt
Normal file
12
android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("l2c_fcr_hook")
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
l2c_fcr_hook.cpp
|
||||
l2c_fcr_hook.h)
|
||||
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
android
|
||||
log)
|
||||
491
android/app/src/main/cpp/l2c_fcr_hook.cpp
Normal file
491
android/app/src/main/cpp/l2c_fcr_hook.cpp
Normal file
@@ -0,0 +1,491 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
#include <android/log.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <sys/system_properties.h>
|
||||
#include "l2c_fcr_hook.h"
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
|
||||
#define LOG_TAG "AirPodsHook"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
||||
|
||||
struct t_l2c_lcb;
|
||||
typedef struct _BT_HDR {
|
||||
uint16_t event;
|
||||
uint16_t len;
|
||||
uint16_t offset;
|
||||
uint16_t layer_specific;
|
||||
uint8_t data[];
|
||||
} BT_HDR;
|
||||
|
||||
typedef struct {
|
||||
uint8_t mode;
|
||||
uint8_t tx_win_sz;
|
||||
uint8_t max_transmit;
|
||||
uint16_t rtrans_tout;
|
||||
uint16_t mon_tout;
|
||||
uint16_t mps;
|
||||
} tL2CAP_FCR;
|
||||
|
||||
// Flow spec structure
|
||||
typedef struct {
|
||||
uint8_t qos_present;
|
||||
uint8_t flow_direction;
|
||||
uint8_t service_type;
|
||||
uint32_t token_rate;
|
||||
uint32_t token_bucket_size;
|
||||
uint32_t peak_bandwidth;
|
||||
uint32_t latency;
|
||||
uint32_t delay_variation;
|
||||
} FLOW_SPEC;
|
||||
|
||||
// Configuration info structure
|
||||
typedef struct {
|
||||
uint16_t result;
|
||||
uint16_t mtu_present;
|
||||
uint16_t mtu;
|
||||
uint16_t flush_to_present;
|
||||
uint16_t flush_to;
|
||||
uint16_t qos_present;
|
||||
FLOW_SPEC qos;
|
||||
uint16_t fcr_present;
|
||||
tL2CAP_FCR fcr;
|
||||
uint16_t fcs_present;
|
||||
uint16_t fcs;
|
||||
uint16_t ext_flow_spec_present;
|
||||
FLOW_SPEC ext_flow_spec;
|
||||
} tL2CAP_CFG_INFO;
|
||||
|
||||
// Basic L2CAP link control block
|
||||
typedef struct {
|
||||
bool wait_ack;
|
||||
// Other FCR fields - not needed for our specific hook
|
||||
} tL2C_FCRB;
|
||||
|
||||
// Forward declarations for needed types
|
||||
struct t_l2c_rcb;
|
||||
struct t_l2c_lcb;
|
||||
|
||||
typedef struct t_l2c_ccb {
|
||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
||||
uint16_t local_cid; // Local CID
|
||||
uint16_t remote_cid; // Remote CID
|
||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
||||
uint8_t ccb_priority; // Channel priority
|
||||
uint16_t tx_mps; // MPS for outgoing messages
|
||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
||||
// State variables
|
||||
bool in_use; // True when channel active
|
||||
uint8_t chnl_state; // Channel state
|
||||
uint8_t local_id; // Transaction ID for local trans
|
||||
uint8_t remote_id; // Transaction ID for remote
|
||||
uint8_t timer_entry; // Timer entry
|
||||
uint8_t is_flushable; // True if flushable
|
||||
// Configuration variables
|
||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
||||
uint16_t config_done; // Configuration bitmask
|
||||
uint16_t remote_config_rsp_result; // Remote config response result
|
||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
||||
// Additional control fields
|
||||
uint8_t remote_credit_count; // Credits sent to peer
|
||||
tL2C_FCRB fcrb; // FCR info
|
||||
bool ecoc; // Enhanced Credit-based mode
|
||||
} tL2C_CCB;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
||||
|
||||
// Add original pointer for BTA_DmSetLocalDiRecord
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
||||
}
|
||||
|
||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
||||
original_l2c_csm_config(p_ccb, event, p_data);
|
||||
|
||||
// Check if this happens during CONFIG_RSP event handling
|
||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
||||
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
|
||||
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
|
||||
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
|
||||
}
|
||||
}
|
||||
|
||||
// Replacement function that does nothing
|
||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
||||
// Just return without doing anything
|
||||
return;
|
||||
}
|
||||
|
||||
// New loader for SDP hook offset (persist.librepods.sdp_offset)
|
||||
uintptr_t loadSdpOffset() {
|
||||
const char* property_name = "persist.librepods.sdp_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read sdp offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed sdp offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse sdp offset from property value: %s", value);
|
||||
}
|
||||
|
||||
LOGI("No sdp offset property present - skipping SDP hook");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
|
||||
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
|
||||
if (original_BTA_DmSetLocalDiRecord) {
|
||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
}
|
||||
|
||||
LOGE("Original BTA_DmSetLocalDiRecord not available");
|
||||
return BTA_FAILURE;
|
||||
}
|
||||
|
||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
||||
const char* property_name = "persist.librepods.hook_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read hook offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse offset from property value: %s", value);
|
||||
}
|
||||
|
||||
LOGI("Using hardcoded fallback offset");
|
||||
return 0x00a55e30;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cCsmConfigOffset() {
|
||||
const char* property_name = "persist.librepods.csm_config_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2c_csm_config offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cuSendPeerInfoReqOffset() {
|
||||
const char* property_name = "persist.librepods.peer_info_req_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t getModuleBase(const char *module_name) {
|
||||
FILE *fp;
|
||||
char line[1024];
|
||||
uintptr_t base_addr = 0;
|
||||
|
||||
fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("Failed to open /proc/self/maps");
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, module_name)) {
|
||||
char *start_addr_str = line;
|
||||
char *end_addr_str = strchr(line, '-');
|
||||
if (end_addr_str) {
|
||||
*end_addr_str = '\0';
|
||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return base_addr;
|
||||
}
|
||||
|
||||
bool findAndHookFunction(const char *library_name) {
|
||||
if (!hook_func) {
|
||||
LOGE("Hook function not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
uintptr_t base_addr = getModuleBase(library_name);
|
||||
if (!base_addr) {
|
||||
LOGE("Failed to get base address of %s", library_name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load all offsets from system properties - no hardcoding
|
||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
||||
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
|
||||
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
|
||||
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
|
||||
uintptr_t sdp_offset = loadSdpOffset();
|
||||
|
||||
bool success = false;
|
||||
|
||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
||||
if (l2c_fcr_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
||||
l2c_fcr_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
||||
return false;
|
||||
}
|
||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
||||
success = true;
|
||||
} else {
|
||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hook l2cu_process_our_cfg_req if offset is available
|
||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
||||
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
|
||||
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
||||
}
|
||||
|
||||
// Hook l2c_csm_config if offset is available
|
||||
if (l2c_csm_config_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
|
||||
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
|
||||
l2c_csm_config_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2c_csm_config, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2c_csm_config");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2c_csm_config hook as offset is not available");
|
||||
}
|
||||
|
||||
// Hook l2cu_send_peer_info_req if offset is available
|
||||
if (l2cu_send_peer_info_req_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
|
||||
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
|
||||
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2cu_send_peer_info_req");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
|
||||
}
|
||||
|
||||
if (sdp_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + sdp_offset);
|
||||
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
|
||||
sdp_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
|
||||
} else {
|
||||
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth JNI library function");
|
||||
}
|
||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth QTI library function");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
LOGI("L2C FCR Hook module initialized");
|
||||
|
||||
hook_func = entries->hook_func;
|
||||
|
||||
return on_library_loaded;
|
||||
}
|
||||
50
android/app/src/main/cpp/l2c_fcr_hook.h
Normal file
50
android/app/src/main/cpp/l2c_fcr_hook.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
||||
|
||||
typedef int (*UnhookFunType)(void *func);
|
||||
|
||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
||||
|
||||
typedef struct {
|
||||
uint32_t version;
|
||||
HookFunType hook_func;
|
||||
UnhookFunType unhook_func;
|
||||
} NativeAPIEntries;
|
||||
|
||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
||||
|
||||
typedef struct t_l2c_ccb tL2C_CCB;
|
||||
typedef struct t_l2c_lcb tL2C_LCB;
|
||||
|
||||
uintptr_t loadHookOffset(const char* package_name);
|
||||
uintptr_t getModuleBase(const char *module_name);
|
||||
uintptr_t loadL2cuProcessCfgReqOffset();
|
||||
uintptr_t loadL2cCsmConfigOffset();
|
||||
uintptr_t loadL2cuSendPeerInfoReqOffset();
|
||||
bool findAndHookFunction(const char *library_path);
|
||||
|
||||
#define SDP_MAX_ATTR_LEN 400
|
||||
|
||||
typedef struct t_sdp_di_record {
|
||||
uint16_t vendor;
|
||||
uint16_t vendor_id_source;
|
||||
uint16_t product;
|
||||
uint16_t version;
|
||||
bool primary_record;
|
||||
char client_executable_url[SDP_MAX_ATTR_LEN];
|
||||
char service_description[SDP_MAX_ATTR_LEN];
|
||||
char documentation_url[SDP_MAX_ATTR_LEN];
|
||||
} tSDP_DI_RECORD;
|
||||
|
||||
typedef enum : uint8_t {
|
||||
BTA_SUCCESS = 0, /* Successful operation. */
|
||||
BTA_FAILURE = 1, /* Generic failure. */
|
||||
BTA_PENDING = 2, /* API cannot be completed right now */
|
||||
BTA_BUSY = 3,
|
||||
BTA_NO_RESOURCES = 4,
|
||||
BTA_WRONG_MODE = 5,
|
||||
} tBTA_STATUS;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
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.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
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.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.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 me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState<String>) {
|
||||
val bluetoothConnectPermissionState = rememberPermissionState(
|
||||
permission = "android.permission.BLUETOOTH_CONNECT"
|
||||
)
|
||||
|
||||
if (bluetoothConnectPermissionState.status.isGranted) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No AirPods connected")
|
||||
}
|
||||
} else {
|
||||
// Permission is not granted, request it
|
||||
Column (
|
||||
modifier = Modifier.padding(24.dp),
|
||||
){
|
||||
val textToShow = if (bluetoothConnectPermissionState.status.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."
|
||||
} 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."
|
||||
}
|
||||
Text(textToShow)
|
||||
Button(onClick = { bluetoothConnectPermissionState.launchPermissionRequest() }) {
|
||||
Text("Request permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun PreviewAirPodsSettingsScreen() {
|
||||
AirPodsSettingsScreen(paddingValues = PaddingValues(0.dp), device = null, service = null, navController = rememberNavController())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package me.kavishdevar.aln
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
|
||||
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
|
||||
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
|
||||
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
|
||||
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
|
||||
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00));
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
const val LEFT = 4
|
||||
const val RIGHT = 2
|
||||
const val CASE = 8
|
||||
}
|
||||
|
||||
object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
|
||||
fun getComponentName(): String? {
|
||||
return when (component) {
|
||||
BatteryComponent.LEFT -> "LEFT"
|
||||
BatteryComponent.RIGHT -> "RIGHT"
|
||||
BatteryComponent.CASE -> "CASE"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusName(): String? {
|
||||
return when (status) {
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.aln.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA"
|
||||
const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA"
|
||||
const val CA_DATA = "me.kavishdevar.aln.CA_DATA"
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = listOf(data[6], data[7])
|
||||
}
|
||||
|
||||
fun isEarDetectionData(data: ByteArray): Boolean {
|
||||
if (data.size != 8) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ANC {
|
||||
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
|
||||
|
||||
var status: Int = 1
|
||||
private set
|
||||
|
||||
fun isANCData(data: ByteArray): Boolean {
|
||||
if (data.size != 11) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = data[7].toInt()
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
4 -> "ADAPTIVE"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BatteryNotification {
|
||||
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
|
||||
|
||||
fun isBatteryData(data: ByteArray): Boolean {
|
||||
if (data.size != 22) {
|
||||
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()
|
||||
}
|
||||
|
||||
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())
|
||||
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
|
||||
Battery(data[17].toInt(), case.level, data[20].toInt())
|
||||
} else {
|
||||
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
|
||||
}
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
|
||||
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
|
||||
enum class NoiseCancellation(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x01)),
|
||||
ON(byteArrayOf(0x02)),
|
||||
TRANSPARENCY(byteArrayOf(0x03)),
|
||||
ADAPTIVE(byteArrayOf(0x04));
|
||||
}
|
||||
|
||||
enum class ConversationAwareness(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x02)),
|
||||
ON(byteArrayOf(0x01));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package me.kavishdevar.aln.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,808 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
||||
import me.kavishdevar.librepods.screens.DebugScreen
|
||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
||||
import me.kavishdevar.librepods.screens.LongPress
|
||||
import me.kavishdevar.librepods.screens.Onboarding
|
||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
||||
import me.kavishdevar.librepods.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
||||
import me.kavishdevar.librepods.screens.VersionScreen
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
getSharedPreferences("settings", MODE_PRIVATE).edit {
|
||||
putLong(
|
||||
"textColor",
|
||||
MaterialTheme.colorScheme.onSurface.toArgb().toLong())}
|
||||
Main()
|
||||
}
|
||||
}
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent) {
|
||||
val data: Uri? = intent.data
|
||||
|
||||
if (data != null && data.scheme == "librepods") {
|
||||
when (data.host) {
|
||||
"add-magic-keys" -> {
|
||||
val queryParams = data.queryParameterNames
|
||||
queryParams.forEach { param ->
|
||||
val value = data.getQueryParameter(param)
|
||||
Log.d("LibrePods", "Parameter: $param = $value")
|
||||
}
|
||||
|
||||
handleAddMagicKeys(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddMagicKeys(uri: Uri) {
|
||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
val irkHex = uri.getQueryParameter("irk")
|
||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
||||
|
||||
try {
|
||||
if (irkHex != null && validateHexInput(irkHex)) {
|
||||
val irkBytes = hexStringToByteArray(irkHex)
|
||||
val irkBase64 = Base64.encode(irkBytes)
|
||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
||||
}
|
||||
|
||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
||||
val result = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
||||
result[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val isConnected = remember { mutableStateOf(false) }
|
||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
||||
val context = LocalContext.current
|
||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||
|
||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE"
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
)
|
||||
}
|
||||
val otherPermissions = listOf(
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
val allPermissions = bluetoothPermissions + otherPermissions
|
||||
|
||||
val permissionState = rememberMultiplePermissionsState(
|
||||
permissions = allPermissions
|
||||
)
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||
}
|
||||
|
||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
){
|
||||
val backButtonBackdrop = rememberLayerBackdrop()
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||
.layerBackdrop(backButtonBackdrop)
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
}
|
||||
) {
|
||||
composable("settings") {
|
||||
if (airPodsService.value != null) {
|
||||
AirPodsSettingsScreen(
|
||||
dev = airPodsService.value?.device,
|
||||
service = airPodsService.value!!,
|
||||
navController = navController,
|
||||
isConnected = isConnected.value,
|
||||
isRemotelyConnected = isRemotelyConnected.value
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("debug") {
|
||||
DebugScreen(navController = navController)
|
||||
}
|
||||
composable("long_press/{bud}") { navBackStackEntry ->
|
||||
LongPress(
|
||||
navController = navController,
|
||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
||||
)
|
||||
}
|
||||
composable("rename") {
|
||||
RenameScreen(navController)
|
||||
}
|
||||
composable("app_settings") {
|
||||
AppSettingsScreen(navController)
|
||||
}
|
||||
composable("troubleshooting") {
|
||||
TroubleshootingScreen(navController)
|
||||
}
|
||||
composable("head_tracking") {
|
||||
HeadTrackingScreen(navController)
|
||||
}
|
||||
composable("onboarding") {
|
||||
Onboarding(navController, context)
|
||||
}
|
||||
composable("accessibility") {
|
||||
AccessibilitySettingsScreen(navController)
|
||||
}
|
||||
composable("transparency_customization") {
|
||||
TransparencySettingsScreen(navController)
|
||||
}
|
||||
composable("hearing_aid") {
|
||||
HearingAidScreen(navController)
|
||||
}
|
||||
composable("hearing_aid_adjustments") {
|
||||
HearingAidAdjustmentsScreen(navController)
|
||||
}
|
||||
composable("adaptive_strength") {
|
||||
AdaptiveStrengthScreen(navController)
|
||||
}
|
||||
composable("camera_control") {
|
||||
CameraControlScreen(navController)
|
||||
}
|
||||
composable("open_source_licenses") {
|
||||
OpenSourceLicensesScreen(navController)
|
||||
}
|
||||
composable("update_hearing_test") {
|
||||
UpdateHearingTestScreen(navController)
|
||||
}
|
||||
composable("version_info") {
|
||||
VersionScreen(navController)
|
||||
}
|
||||
composable("hearing_protection") {
|
||||
HearingProtectionScreen(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showBackButton = remember{ mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
|
||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBackButton.value,
|
||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||
)
|
||||
) {
|
||||
StyledIconButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
icon = "",
|
||||
darkMode = isSystemInDarkTheme(),
|
||||
backdrop = backButtonBackdrop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceConnection = remember {
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService.value = binder.getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (airPodsService.value?.isConnectedLocally == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
PermissionsScreen(
|
||||
permissionState = permissionState,
|
||||
canDrawOverlays = canDrawOverlays,
|
||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionState: MultiplePermissionsState,
|
||||
canDrawOverlays: Boolean,
|
||||
onOverlaySettingsReturn: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uDBC2\uDEB7",
|
||||
style = TextStyle(
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.scale(pulseScale)
|
||||
) {
|
||||
val radius = size.minDimension / 2.2f
|
||||
val centerX = size.width / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
rotate(degrees = 45f) {
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.1f),
|
||||
radius = radius * 1.3f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.2f),
|
||||
radius = radius * 1.1f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Permission Required",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_required),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PermissionCard(
|
||||
title = "Bluetooth Permissions",
|
||||
description = "Required to communicate with your AirPods",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("BLUETOOTH")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Notification Permission",
|
||||
description = "To show battery status",
|
||||
icon = Icons.Default.Notifications,
|
||||
isGranted = permissionState.permissions.find {
|
||||
it.permission == "android.permission.POST_NOTIFICATIONS"
|
||||
}?.status?.isGranted == true,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Phone Permissions",
|
||||
description = "For answering calls with Head Gestures",
|
||||
icon = Icons.Default.Phone,
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("PHONE") || it.permission.contains("CALLS")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Display Over Other Apps",
|
||||
description = "For popup animations when AirPods connect",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
|
||||
isGranted = canDrawOverlays,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchMultiplePermissionRequest() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Ask for regular permissions",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
onOverlaySettingsReturn()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (canDrawOverlays) Color.Gray else accentColor
|
||||
),
|
||||
enabled = !canDrawOverlays,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!canDrawOverlays && basicPermissionsGranted) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
context.getSharedPreferences("settings", MODE_PRIVATE).edit {
|
||||
putBoolean("overlay_permission_skipped", true)
|
||||
}
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF757575)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue without overlay",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ImageVector,
|
||||
isGranted: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
accentColor: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||
alpha = 0.15f
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = if (isGranted) accentColor else Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isGranted) "✓" else "!",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||
|
||||
private var airPodsService: AirPodsService? = null
|
||||
private var isBound = false
|
||||
|
||||
private var isNoiseControlExpandedState by mutableStateOf(false)
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService = binder.getService()
|
||||
isBound = true
|
||||
Log.d("QSActivity", "Service bound")
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
isBound = false
|
||||
airPodsService = null
|
||||
Log.d("QSActivity", "Service unbound")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
||||
window.setGravity(Gravity.BOTTOM)
|
||||
|
||||
Intent(this, AirPodsService::class.java).also { intent ->
|
||||
bindService(intent, connection, BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isBound) {
|
||||
unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableDismissBox(
|
||||
onDismiss: () -> Unit,
|
||||
onlyCollapseWhenClicked: () -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = 400f
|
||||
|
||||
val animatedOffset = remember { Animatable(0f) }
|
||||
val animatedScale = remember { Animatable(1f) }
|
||||
val animatedAlpha = remember { Animatable(1f) }
|
||||
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
targetValue = if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
|
||||
1f - dragProgress
|
||||
} else 1f,
|
||||
label = "BackgroundFade"
|
||||
)
|
||||
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) {
|
||||
if (abs(dragOffset) < dismissThreshold) {
|
||||
val springSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessHigh,
|
||||
visibilityThreshold = 0.1f
|
||||
)
|
||||
launch { animatedOffset.animateTo(0f, springSpec) }
|
||||
launch { animatedScale.animateTo(1f, springSpec) }
|
||||
launch { animatedAlpha.animateTo(1f, tween(100)) }
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragOffset, isDragging) {
|
||||
if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||
|
||||
animatedOffset.snapTo(dragOffset)
|
||||
animatedScale.snapTo(1f - dragProgress * 0.3f)
|
||||
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { isDragging = true },
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (abs(dragOffset) > dismissThreshold) {
|
||||
coroutineScope.launch {
|
||||
val direction = if (dragOffset > 0) 1f else -1f
|
||||
|
||||
launch {
|
||||
animatedOffset.animateTo(
|
||||
direction * 1500f,
|
||||
tween(350, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
launch { animatedScale.animateTo(0.7f, tween(350)) }
|
||||
launch { animatedAlpha.animateTo(0f, tween(250)) }
|
||||
|
||||
kotlinx.coroutines.delay(350)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = { isDragging = false },
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onlyCollapseWhenClicked()
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
translationY = animatedOffset.value,
|
||||
scaleX = animatedScale.value,
|
||||
scaleY = animatedScale.value,
|
||||
alpha = animatedAlpha.value
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun NewControlCenterDialogContent(
|
||||
service: AirPodsService?,
|
||||
isNoiseControlExpanded: Boolean,
|
||||
onNoiseControlExpandedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val textColor = Color.White
|
||||
|
||||
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
|
||||
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
|
||||
val availableModes = remember(isOffModeEnabled) {
|
||||
mutableListOf(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
NoiseControlMode.NOISE_CANCELLATION
|
||||
).apply {
|
||||
if (isOffModeEnabled) {
|
||||
add(0, NoiseControlMode.OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
|
||||
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
|
||||
val animatedVolumeFraction by animateFloatAsState(
|
||||
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "VolumeAnimation"
|
||||
)
|
||||
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
|
||||
var isDraggingVolume by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
|
||||
if (!isDraggingVolume) {
|
||||
liveDragFraction = animatedVolumeFraction
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(service, availableModes) {
|
||||
val ancReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
|
||||
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
|
||||
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (availableModes.contains(newMode)) {
|
||||
currentAncMode = newMode
|
||||
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(ancReceiver, filter)
|
||||
}
|
||||
|
||||
service?.let {
|
||||
val initialModeOrdinal = it.getANC().minus(1)
|
||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (!availableModes.contains(initialMode)) {
|
||||
initialMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
currentAncMode = initialMode
|
||||
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
|
||||
}
|
||||
|
||||
onDispose {
|
||||
context.unregisterReceiver(ancReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val volumeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
|
||||
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (newVolume != currentVolumeInt) {
|
||||
currentVolumeInt = newVolume
|
||||
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
|
||||
context.registerReceiver(volumeReceiver, filter)
|
||||
onDispose {
|
||||
context.unregisterReceiver(volumeReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 24.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent()
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (service != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Device Icon",
|
||||
tint = textColor.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = deviceName,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
VerticalVolumeSlider(
|
||||
displayFraction = animatedVolumeFraction,
|
||||
maxVolume = maxVolume,
|
||||
onVolumeChange = { newVolume ->
|
||||
currentVolumeInt = newVolume
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
|
||||
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
|
||||
},
|
||||
initialFraction = animatedVolumeFraction,
|
||||
onDragStateChange = { dragging -> isDraggingVolume = dragging },
|
||||
baseSliderHeight = 400.dp,
|
||||
baseSliderWidth = 145.dp,
|
||||
baseCornerRadius = 48.dp,
|
||||
maxStretchFactor = 1.15f,
|
||||
minCompressionFactor = 0.875f,
|
||||
stretchSensitivity = 0.3f,
|
||||
compressionSensitivity = 0.3f,
|
||||
cornerRadiusChangeFactor = -0.5f,
|
||||
directionalStretchRatio = 0.75f,
|
||||
modifier = Modifier
|
||||
.width(145.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 72.dp)
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isNoiseControlExpanded,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "NoiseControlCrossfade"
|
||||
) { expanded ->
|
||||
if (expanded) {
|
||||
ControlCenterNoiseControlSegmentedButton(
|
||||
availableModes = availableModes,
|
||||
selectedMode = currentAncMode,
|
||||
onModeSelected = { newMode ->
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
value = newMode.ordinal + 1
|
||||
)
|
||||
currentAncMode = newMode
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
|
||||
AdaptiveRainbowBrush
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = noiseControlButtonBrush ?:
|
||||
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
|
||||
)
|
||||
.clickable(
|
||||
onClick = { onNoiseControlExpandedChange(true) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = getModeIconRes(currentAncMode)),
|
||||
contentDescription = getModeLabel(currentAncMode),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = getModeLabel(currentAncMode),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val newState = !isConvAwarenessEnabled
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
value = newState
|
||||
)
|
||||
isConvAwarenessEnabled = newState
|
||||
},
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Conversational Awareness",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Conversational\nAwareness",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Loading...", color = textColor)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AboutCard(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.about),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.model.displayName,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.actualModelNumber,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
val serialNumbers = listOf(
|
||||
airpodsInstance.serialNumber?: "",
|
||||
" ${airpodsInstance.leftSerialNumber}",
|
||||
" ${airpodsInstance.rightSerialNumber}"
|
||||
)
|
||||
val serialNumber = remember { mutableStateOf(0) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.serial_number),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = serialNumbers[serialNumber.value],
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "version_info",
|
||||
navController = navController,
|
||||
name = stringResource(R.string.version),
|
||||
currentState = airpodsInstance.version3,
|
||||
independent = false,
|
||||
height = rowHeight.value + 32.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.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 me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
|
||||
) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.audio),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.personalized_volume),
|
||||
description = stringResource(R.string.personalized_volume_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
||||
NavigationButton(
|
||||
to = "adaptive_strength",
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(rememberNavController())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun BatteryIndicator(
|
||||
batteryPercentage: Int,
|
||||
charging: Boolean = false,
|
||||
prefix: String = "",
|
||||
previousCharging: Boolean = false,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val batteryFillColor = if (batteryPercentage > 25)
|
||||
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||
|
||||
val initialScale = if (previousCharging) 1f else 0f
|
||||
val scaleAnim = remember { Animatable(initialScale) }
|
||||
val targetScale = if (charging) 1f else 0f
|
||||
|
||||
LaunchedEffect(previousCharging, charging) {
|
||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor), // just for haze to work
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { batteryPercentage / 100f },
|
||||
modifier = Modifier.size(40.dp),
|
||||
color = batteryFillColor,
|
||||
gapSize = 0.dp,
|
||||
strokeCap = StrokeCap.Round,
|
||||
strokeWidth = 4.dp,
|
||||
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "\uDBC0\uDEE6",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = batteryFillColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.scale(scaleAnim.value)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$prefix $batteryPercentage%",
|
||||
color = batteryTextColor,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
||||
|
||||
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
||||
|
||||
@Suppress("DEPRECATION") val batteryReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
batteryStatus.value =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra("data")
|
||||
}?.toList() ?: listOf()
|
||||
}
|
||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
catch (_: IllegalArgumentException) {
|
||||
Log.wtf("BatteryReceiver", "Receiver already unregistered")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(context) {
|
||||
val batteryIntentFilter = IntentFilter()
|
||||
.apply {
|
||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(
|
||||
batteryReceiver,
|
||||
batteryIntentFilter,
|
||||
Context.RECEIVER_EXPORTED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
previousBatteryStatus.value = batteryStatus.value
|
||||
batteryStatus.value = service.getBattery()
|
||||
|
||||
if (preview) {
|
||||
batteryStatus.value = listOf(
|
||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
|
||||
)
|
||||
previousBatteryStatus.value = batteryStatus.value
|
||||
}
|
||||
|
||||
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
||||
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
||||
val leftLevel = left?.level ?: 0
|
||||
val rightLevel = right?.level ?: 0
|
||||
val caseLevel = case?.level ?: 0
|
||||
val leftCharging = left?.status == BatteryStatus.CHARGING
|
||||
val rightCharging = right?.status == BatteryStatus.CHARGING
|
||||
val caseCharging = case?.status == BatteryStatus.CHARGING
|
||||
|
||||
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
||||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
||||
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
|
||||
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
|
||||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
|
||||
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
|
||||
|
||||
val singleDisplayed = remember { mutableStateOf(false) }
|
||||
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) {
|
||||
return
|
||||
}
|
||||
val budsRes = airpodsInstance.model.budsRes
|
||||
val caseRes = airpodsInstance.model.caseRes
|
||||
|
||||
Row {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.5f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image (
|
||||
bitmap = ImageBitmap.imageResource(budsRes),
|
||||
contentDescription = stringResource(R.string.buds),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
if (
|
||||
leftCharging == rightCharging &&
|
||||
(leftLevel - rightLevel) in -3..3
|
||||
)
|
||||
{
|
||||
BatteryIndicator(
|
||||
leftLevel.coerceAtMost(rightLevel),
|
||||
leftCharging,
|
||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
||||
)
|
||||
singleDisplayed.value = true
|
||||
}
|
||||
else {
|
||||
singleDisplayed.value = false
|
||||
Row (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
leftLevel,
|
||||
leftCharging,
|
||||
"\uDBC6\uDCE5",
|
||||
previousCharging = prevLeftCharging
|
||||
)
|
||||
}
|
||||
if (leftLevel > 0 && rightLevel > 0)
|
||||
{
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
||||
{
|
||||
BatteryIndicator(
|
||||
rightLevel,
|
||||
rightCharging,
|
||||
"\uDBC6\uDCE8",
|
||||
previousCharging = prevRightCharging
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(caseRes),
|
||||
contentDescription = stringResource(R.string.case_alt),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
caseLevel,
|
||||
caseCharging,
|
||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
||||
previousCharging = prevCaseCharging
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryViewPreview() {
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier.background(bg)
|
||||
) {
|
||||
BatteryView(AirPodsService(), preview = true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.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 dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun CallControlSettings(hazeState: HazeState) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.call_controls),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
||||
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
|
||||
var flipped by remember {
|
||||
mutableStateOf(
|
||||
callControlEnabledValue.contentEquals(
|
||||
byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||
|
||||
var showSinglePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||
|
||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
) {
|
||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
||||
flipped = newFlipped
|
||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
||||
Log.d(
|
||||
"CallControlSettings",
|
||||
"Control command received, flipped: $newFlipped"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(flipped) {
|
||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.answer_call),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.press_once),
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showSinglePressDropdown) {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = now
|
||||
} else {
|
||||
if (now - lastDismissTimeSingle > 250L) {
|
||||
touchOffsetSingle = offset
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetSingle = offset
|
||||
if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) {
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
lastDismissTimeSingle = now
|
||||
parentDragActiveSingle = true
|
||||
parentHoveredIndexSingle = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetSingle ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndexSingle = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
parentHoveredIndexSingle = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mute_unmute),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionSingle = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = singlePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showSinglePressDropdown,
|
||||
onDismissRequest = {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = singlePressAction,
|
||||
touchOffset = touchOffsetSingle,
|
||||
boxPosition = boxPositionSingle,
|
||||
externalHoveredIndex = parentHoveredIndexSingle,
|
||||
externalDragActive = parentDragActiveSingle,
|
||||
onOptionSelected = { option ->
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDoublePressDropdown) {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = now
|
||||
} else {
|
||||
if (now - lastDismissTimeDouble > 250L) {
|
||||
touchOffsetDouble = offset
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetDouble = offset
|
||||
if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) {
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
lastDismissTimeDouble = now
|
||||
parentDragActiveDouble = true
|
||||
parentHoveredIndexDouble = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetDouble ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndexDouble = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
parentHoveredIndexDouble = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.hang_up),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionDouble = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = doublePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDoublePressDropdown,
|
||||
onDismissRequest = {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = doublePressAction,
|
||||
touchOffset = touchOffsetDouble,
|
||||
boxPosition = boxPositionDouble,
|
||||
externalHoveredIndex = parentHoveredIndexDouble,
|
||||
externalDragActive = parentDragActiveDouble,
|
||||
onOptionSelected = { option ->
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun CallControlSettingsPreview() {
|
||||
CallControlSettings(HazeState())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun ConfirmationDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
title: String,
|
||||
message: String,
|
||||
confirmText: String = "Enable",
|
||||
dismissText: String = "Cancel",
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit = { showDialog.value = false },
|
||||
hazeState: HazeState,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
if (showDialog.value) {
|
||||
Dialog(onDismissRequest = { showDialog.value = false }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// .fillMaxWidth(0.75f)
|
||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||
.background(Color.Transparent, RoundedCornerShape(14.dp))
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.hazeEffect(
|
||||
hazeState,
|
||||
style = CupertinoMaterials.regular(
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
message,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
var leftPressed by remember { mutableStateOf(false) }
|
||||
var rightPressed by remember { mutableStateOf(false) }
|
||||
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val position = event.changes.first().position
|
||||
val width = size.width.toFloat()
|
||||
val height = size.height.toFloat()
|
||||
val isWithinBounds = position.y >= 0 && position.y <= height
|
||||
val isLeft = position.x < width / 2
|
||||
event.changes.first().consume()
|
||||
when (event.type) {
|
||||
PointerEventType.Press -> {
|
||||
if (isWithinBounds) {
|
||||
leftPressed = isLeft
|
||||
rightPressed = !isLeft
|
||||
} else {
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
PointerEventType.Move -> {
|
||||
if (isWithinBounds) {
|
||||
leftPressed = isLeft
|
||||
rightPressed = !isLeft
|
||||
} else {
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
PointerEventType.Release -> {
|
||||
if (isWithinBounds) {
|
||||
if (leftPressed) {
|
||||
onDismiss()
|
||||
} else if (rightPressed) {
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
leftPressed = false
|
||||
rightPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (leftPressed) pressedColor else Color.Transparent),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = dismissText,
|
||||
style = TextStyle(
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0x40888888))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.background(if (rightPressed) pressedColor else Color.Transparent),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = confirmText,
|
||||
style = TextStyle(
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConnectionSettings() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_ear_detection",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.automatically_connect),
|
||||
description = stringResource(R.string.automatically_connect_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConnectionSettingsPreview() {
|
||||
ConnectionSettings()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val SelectedColorBlue = Color(0xFF0A84FF)
|
||||
private val UnselectedColor = Color(0x593C3C3E)
|
||||
private val TextColor = Color.White
|
||||
private val IconTint = Color.White
|
||||
|
||||
@Composable
|
||||
fun ControlCenterButton(
|
||||
label: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
iconAreaSize: Dp,
|
||||
isSelected: Boolean,
|
||||
backgroundBrush: Brush? = null
|
||||
) {
|
||||
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = targetBackgroundColor,
|
||||
label = "ButtonBackground"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(iconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = IconTint,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = TextColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
|
||||
private val ContainerColor = Color(0x593C3C3E)
|
||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
|
||||
private val TextColor = Color.White
|
||||
private val IconTintUnselected = Color.White
|
||||
private val IconTintSelected = Color.White
|
||||
|
||||
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
|
||||
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
|
||||
Color(0xFFB03A2F)
|
||||
)
|
||||
)
|
||||
|
||||
internal val IconAreaSize = 72.dp
|
||||
private val IconSize = 42.dp
|
||||
private val IconRowHeight = IconAreaSize + 12.dp
|
||||
private val TextRowHeight = 24.dp
|
||||
private val TextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
fun ControlCenterNoiseControlSegmentedButton(
|
||||
modifier: Modifier = Modifier,
|
||||
availableModes: List<NoiseControlMode>,
|
||||
selectedMode: NoiseControlMode,
|
||||
onModeSelected: (NoiseControlMode) -> Unit
|
||||
) {
|
||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||
val density = LocalDensity.current
|
||||
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||
val itemCount = availableModes.size
|
||||
|
||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||
if (itemCount > 0 && iconRowWidthPx > 0) {
|
||||
iconRowWidthPx / itemCount
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
|
||||
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
|
||||
|
||||
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
|
||||
if (itemSlotWidthPx > 0) {
|
||||
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
|
||||
slotCenterPx - (iconAreaSizePx / 2f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
val indicatorOffset: Dp by animateDpAsState(
|
||||
targetValue = with(density) { targetIndicatorStartPx.toDp() },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "IndicatorOffset"
|
||||
)
|
||||
|
||||
val indicatorBackground = remember(selectedMode) {
|
||||
when (selectedMode) {
|
||||
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
|
||||
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IconRowHeight)
|
||||
.clip(CircleShape)
|
||||
.background(ContainerColor)
|
||||
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = indicatorOffset)
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorBackground)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
NoiseControlIconItem(
|
||||
modifier = Modifier.size(IconAreaSize),
|
||||
mode = mode,
|
||||
isSelected = isSelected,
|
||||
onClick = { onModeSelected(mode) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TextRowHeight),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
Text(
|
||||
text = getModeLabel(mode),
|
||||
color = TextColor,
|
||||
fontSize = TextSize,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoiseControlIconItem(
|
||||
modifier: Modifier = Modifier,
|
||||
mode: NoiseControlMode,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val iconRes = remember(mode) { getModeIconRes(mode) }
|
||||
|
||||
val tint = IconTintUnselected
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = getModeLabel(mode),
|
||||
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
|
||||
modifier = Modifier.size(IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun HearingHealthSettings(navController: NavController) {
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_health),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "hearing_protection",
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
NavigationButton(
|
||||
to = "hearing_aid",
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun MicrophoneSettings(hazeState: HazeState) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
}?.value?.get(0) ?: 0x00.toByte()
|
||||
|
||||
var selectedMode by remember {
|
||||
mutableStateOf(
|
||||
when (micModeValue) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
)
|
||||
}
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
val reopenThresholdMs = 250L
|
||||
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
) {
|
||||
selectedMode = when (controlCommand.value[0]) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDropdown) {
|
||||
showDropdown = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > reopenThresholdMs) {
|
||||
touchOffset = offset
|
||||
showDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
|
||||
showDropdown = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndex = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
val options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
val byteValue = when (option) {
|
||||
options[0] -> 0x00
|
||||
options[1] -> 0x01
|
||||
options[2] -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
byteArrayOf(byteValue.toByte())
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.microphone_mode),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedMode,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = {
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
),
|
||||
selectedOption = selectedMode,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
val byteValue = when (option) {
|
||||
microphoneAutomaticText -> 0x00
|
||||
microphoneAlwaysRightText -> 0x01
|
||||
microphoneAlwaysLeftText -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
byteArrayOf(byteValue.toByte())
|
||||
)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun MicrophoneSettingsPreview() {
|
||||
MicrophoneSettings(HazeState())
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.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.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.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.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun NavigationButton(
|
||||
to: String,
|
||||
name: String,
|
||||
navController: NavController, onClick: (() -> Unit)? = null,
|
||||
independent: Boolean = true,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
currentState: String? = null,
|
||||
height: Dp = 58.dp,
|
||||
) {
|
||||
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))
|
||||
Column {
|
||||
if (title != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
|
||||
.height(height)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (onClick != null) onClick() else navController.navigate(to)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (currentState != null) {
|
||||
Text(
|
||||
text = currentState,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = if (currentState != null) 6.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
if (description != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
// modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NavigationButtonPreview() {
|
||||
NavigationButton("to", "Name", NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.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.librepods.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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.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.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.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(
|
||||
service: AirPodsService,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||
|
||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
offListeningModeListener
|
||||
)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val 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) {
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) {
|
||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||
}
|
||||
|
||||
when (noiseControlMode.value) {
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
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)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
)
|
||||
)
|
||||
}
|
||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||
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(28.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 -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
// Call onModeSelected which now handles service call but not callback
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(26.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(top = 4.dp)
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
Text(
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
NoiseControlSettings(AirPodsService())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = Color(0x40888888)
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
|
||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp))
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "long_press/Left",
|
||||
name = stringResource(R.string.left),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = leftActionText,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = dividerColor,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "long_press/Right",
|
||||
name = stringResource(R.string.right),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = rightActionText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PressAndHoldSettingsPreview() {
|
||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refraction
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
onClick: () -> Unit,
|
||||
backdrop: Backdrop,
|
||||
modifier: Modifier = Modifier,
|
||||
isInteractive: Boolean = true,
|
||||
tint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
maxScale: Float = 0.1f,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier
|
||||
.then(
|
||||
if (!isInteractive) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
blur(16f.dp.toPx())
|
||||
},
|
||||
layerBlock = null,
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = null,
|
||||
highlight = { Highlight.Ambient.copy(alpha = 0f) }
|
||||
)
|
||||
} else {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(2f.dp.toPx())
|
||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1f + maxScale, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
onClick = onClick
|
||||
)
|
||||
.then(
|
||||
if (isInteractive) {
|
||||
Modifier.pointerInput(animationScope) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
tryAwaitRelease()
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.height(48f.dp)
|
||||
.padding(horizontal = 16f.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
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.fillMaxWidth
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledDropdown(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
touchOffset: Offset?,
|
||||
boxPosition: Offset,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
externalHoveredIndex: Int? = null,
|
||||
externalDragActive: Boolean = false,
|
||||
hazeState: HazeState,
|
||||
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
|
||||
) {
|
||||
if (expanded) {
|
||||
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
|
||||
Popup(
|
||||
offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.width(300.dp)
|
||||
.background(Color.Transparent)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val itemHeight = 48.dp
|
||||
|
||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(externalHoveredIndex, externalDragActive) {
|
||||
if (externalDragActive) {
|
||||
hoveredIndex = externalHoveredIndex
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
popupSize = coordinates.size
|
||||
}
|
||||
.pointerInput(popupSize) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
hoveredIndex = (offset.y / itemHeight.toPx()).toInt()
|
||||
lastDragPosition = offset
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val y = change.position.y
|
||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||
lastDragPosition = change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
val pos = lastDragPosition
|
||||
val withinBounds = pos != null &&
|
||||
pos.x >= 0f && pos.y >= 0f &&
|
||||
pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat()
|
||||
|
||||
if (withinBounds) {
|
||||
hoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
}
|
||||
}
|
||||
onDismissRequest()
|
||||
} else {
|
||||
hoveredIndex = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
options.forEachIndexed { index, text ->
|
||||
val isHovered =
|
||||
if (externalDragActive && externalHoveredIndex != null) {
|
||||
index == externalHoveredIndex
|
||||
} else {
|
||||
index == hoveredIndex
|
||||
}
|
||||
val isSystemInDarkTheme = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.background(
|
||||
Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onOptionSelected(text)
|
||||
onDismissRequest()
|
||||
}
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.regular(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha = 1f
|
||||
backgroundColor = if (isSystemInDarkTheme) {
|
||||
Color(0xB02C2C2E)
|
||||
} else {
|
||||
Color(0xB0FFFFFF)
|
||||
}
|
||||
tints = if (isHovered) listOf(
|
||||
HazeTint(
|
||||
color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
|
||||
)
|
||||
) else listOf()
|
||||
})
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Checkbox(
|
||||
checked = text == selectedOption,
|
||||
onCheckedChange = { onOptionSelected(text) },
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index != options.lastIndex) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledIconButton(
|
||||
onClick: () -> Unit,
|
||||
icon: String,
|
||||
darkMode: Boolean,
|
||||
tint: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(56.dp),
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(56.dp) },
|
||||
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 12f.dp,
|
||||
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1.5f, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||
|
||||
val shape = RoundedCornerShape(56.dp)
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
val innerShadowOffset = 4f.dp.toPx()
|
||||
val innerShadowBlurRadius = 4f.dp.toPx()
|
||||
|
||||
innerShadowLayer.alpha = progress
|
||||
innerShadowLayer.renderEffect =
|
||||
BlurEffect(
|
||||
innerShadowBlurRadius,
|
||||
innerShadowBlurRadius,
|
||||
TileMode.Decal
|
||||
)
|
||||
innerShadowLayer.record {
|
||||
drawOutline(outline, Color.Black.copy(0.2f))
|
||||
translate(0f, innerShadowOffset) {
|
||||
drawOutline(
|
||||
outline,
|
||||
Color.Transparent,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLayer(innerShadowLayer)
|
||||
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
||||
)
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
||||
// blur(24f, TileMode.Decal)
|
||||
},
|
||||
)
|
||||
.pointerInput(animationScope) {
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
.size(48.dp),
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
Scaffold(
|
||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) { paddingValues ->
|
||||
val topPadding = paddingValues.calculateTopPadding()
|
||||
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
|
||||
) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.height(64.dp + topPadding)
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeEffect(state = hazeState) {
|
||||
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
|
||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
actionButtons.forEach { actionButton ->
|
||||
actionButton(backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
content(topPadding + 64.dp, hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { _, _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp) -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { spacerValue, _ ->
|
||||
content(spacerValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
data class SelectItem(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
data class SelectItem2(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: () -> Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
fun StyledSelectList(
|
||||
items: List<SelectItem>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val visibleItems = items.filter { it.enabled }
|
||||
visibleItems.forEachIndexed { index, item ->
|
||||
val isFirst = index == 0
|
||||
val isLast = index == visibleItems.size - 1
|
||||
val hasIcon = item.iconRes != null
|
||||
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (hasIcon) 72.dp else 55.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
itemBackgroundColor = backgroundColor
|
||||
item.onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (hasIcon) {
|
||||
Icon(
|
||||
painter = painterResource(item.iconRes!!),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = if (hasIcon) 8.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
item.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
item.description?.let {
|
||||
Text(
|
||||
it,
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val floatAnimateState by animateFloatAsState(
|
||||
targetValue = if (item.selected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
if (!isLast) {
|
||||
if (hasIcon) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 72.dp, end = 20.dp)
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
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.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.heightIn
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun rememberMomentumAnimation(
|
||||
maxScale: Float,
|
||||
progressAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(1f, 1000f, 0.01f),
|
||||
velocityAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.5f, 250f, 5f),
|
||||
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.4f, 400f, 0.01f),
|
||||
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.6f, 400f, 0.01f)
|
||||
): MomentumAnimation {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
return remember(
|
||||
maxScale,
|
||||
animationScope,
|
||||
progressAnimationSpec,
|
||||
velocityAnimationSpec,
|
||||
scaleXAnimationSpec,
|
||||
scaleYAnimationSpec
|
||||
) {
|
||||
MomentumAnimation(
|
||||
maxScale = maxScale,
|
||||
animationScope = animationScope,
|
||||
progressAnimationSpec = progressAnimationSpec,
|
||||
velocityAnimationSpec = velocityAnimationSpec,
|
||||
scaleXAnimationSpec = scaleXAnimationSpec,
|
||||
scaleYAnimationSpec = scaleYAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MomentumAnimation(
|
||||
val maxScale: Float,
|
||||
private val animationScope: CoroutineScope,
|
||||
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
|
||||
) {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
|
||||
private val progressAnimation = Animatable(0f)
|
||||
private val velocityAnimation = Animatable(0f)
|
||||
private val scaleXAnimation = Animatable(1f)
|
||||
private val scaleYAnimation = Animatable(1f)
|
||||
|
||||
val progress: Float get() = progressAnimation.value
|
||||
val velocity: Float get() = velocityAnimation.value
|
||||
val scaleX: Float get() = scaleXAnimation.value
|
||||
val scaleY: Float get() = scaleYAnimation.value
|
||||
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val modifier: Modifier = Modifier.pointerInput(Unit) {
|
||||
inspectDragGestures(
|
||||
onDragStart = {
|
||||
isDragging = true
|
||||
velocityTracker.resetTracking()
|
||||
startPressingAnimation()
|
||||
},
|
||||
onDragEnd = { change ->
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
}
|
||||
) { change, _ ->
|
||||
isDragging = true
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVelocity(velocity: Velocity) {
|
||||
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
private fun settleVelocity() {
|
||||
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
fun startPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
|
||||
fun endPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledSlider(
|
||||
label: String? = null,
|
||||
mutableFloatState: MutableFloatState,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
snapPoints: List<Float> = emptyList(),
|
||||
snapThreshold: Float = 0.05f,
|
||||
startIcon: String? = null,
|
||||
endIcon: String? = null,
|
||||
startLabel: String? = null,
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null
|
||||
) {
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isLightTheme = !isSystemInDarkTheme()
|
||||
val accentColor =
|
||||
if (isLightTheme) Color(0xFF0088FF)
|
||||
else Color(0xFF0091FF)
|
||||
val trackColor =
|
||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||
else Color(0xFF787880).copy(0.36f)
|
||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||
|
||||
val fraction by remember {
|
||||
derivedStateOf {
|
||||
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val sliderBackdrop = rememberLayerBackdrop()
|
||||
val trackWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val trackPositionState = remember { mutableFloatStateOf(0f) }
|
||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||
|
||||
val content = @Composable {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.layerBackdrop(sliderBackdrop)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = startLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = endLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
if (startIcon != null) {
|
||||
Text(
|
||||
text = startIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
startIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
|
||||
.onGloballyPositioned {
|
||||
trackPositionState.floatValue =
|
||||
it.positionInParent().y + it.size.height / 2f
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(trackColor)
|
||||
.height(6f.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(accentColor)
|
||||
.height(6f.dp)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val fraction = fraction
|
||||
val width =
|
||||
(fraction * constraints.maxWidth).fastRoundToInt()
|
||||
layout(width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (endIcon != null) {
|
||||
Text(
|
||||
text = endIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
endIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() }
|
||||
val startOffset =
|
||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||
density
|
||||
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
snapPoints.forEach { point ->
|
||||
val pointFraction =
|
||||
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX =
|
||||
startOffset + pointFraction * trackWidth - 4.dp.toPx()
|
||||
}
|
||||
.size(2.dp)
|
||||
.background(
|
||||
trackColor,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
// val startOffset =
|
||||
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
|
||||
// translationX =
|
||||
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
|
||||
val startOffset =
|
||||
if (startIcon != null)
|
||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||
else
|
||||
with(density) { 8.dp.toPx() }
|
||||
|
||||
translationX =
|
||||
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
|
||||
.fastCoerceIn(
|
||||
startOffset - size.width / 4f,
|
||||
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
|
||||
)
|
||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||
}
|
||||
.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
val trackWidth = trackWidthState.floatValue
|
||||
if (trackWidth > 0f) {
|
||||
val targetFraction = fraction + delta / trackWidth
|
||||
val targetValue =
|
||||
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
|
||||
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
|
||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||
targetValue,
|
||||
snapPoints,
|
||||
snapThreshold
|
||||
) else targetValue
|
||||
onValueChange(snappedValue)
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
},
|
||||
onDragStopped = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
|
||||
}
|
||||
)
|
||||
.then(momentumAnimation.modifier)
|
||||
.drawBackdrop(
|
||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||
{ RoundedCornerShape(28.dp) },
|
||||
highlight = {
|
||||
val progress = momentumAnimation.progress
|
||||
Highlight.Ambient.copy(alpha = progress)
|
||||
},
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 4f.dp,
|
||||
color = Color.Black.copy(0.05f)
|
||||
)
|
||||
},
|
||||
innerShadow = {
|
||||
val progress = momentumAnimation.progress
|
||||
InnerShadow(
|
||||
radius = 4f.dp * progress,
|
||||
alpha = progress
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
scaleX = momentumAnimation.scaleX
|
||||
scaleY = momentumAnimation.scaleY
|
||||
val velocity = momentumAnimation.velocity / 5000f
|
||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = momentumAnimation.progress
|
||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||
},
|
||||
effects = {
|
||||
val progress = momentumAnimation.progress
|
||||
blur(8f.dp.toPx() * (1f - progress))
|
||||
refractionWithDispersion(
|
||||
height = 6f.dp.toPx() * progress,
|
||||
amount = size.height / 2f * progress
|
||||
)
|
||||
}
|
||||
)
|
||||
.size(40f.dp, 24f.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelTextColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
.heightIn(min = 58.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
|
||||
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
||||
val nearest = points.minByOrNull { abs(it - value) } ?: value
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box (
|
||||
Modifier.align(Alignment.Center)
|
||||
)
|
||||
{
|
||||
StyledSlider(
|
||||
mutableFloatState = a,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.Animatable
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun StyledSwitch(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
|
||||
val trackWidth = 64.dp
|
||||
val trackHeight = 28.dp
|
||||
val thumbHeight = 24.dp
|
||||
val thumbWidth = 39.dp
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val switchBackdrop = rememberLayerBackdrop()
|
||||
val fraction by remember {
|
||||
derivedStateOf { if (checked) 1f else 0f }
|
||||
}
|
||||
val animatedFraction = remember { Animatable(fraction) }
|
||||
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
|
||||
val totalDrag = remember { mutableFloatStateOf(0f) }
|
||||
val tapThreshold = 10f
|
||||
val isFirstComposition = remember { mutableStateOf(true) }
|
||||
LaunchedEffect(checked) {
|
||||
if (!isFirstComposition.value) {
|
||||
coroutineScope {
|
||||
launch {
|
||||
val targetColor = if (checked) onColor else offColor
|
||||
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
|
||||
}
|
||||
launch {
|
||||
val targetFrac = if (checked) 1f else 0f
|
||||
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
||||
}
|
||||
if (progressAnimation.value > 0f) return@coroutineScope
|
||||
launch {
|
||||
progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing))
|
||||
progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing))
|
||||
}
|
||||
}
|
||||
}
|
||||
isFirstComposition.value = false
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(trackWidth)
|
||||
.height(trackHeight),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(switchBackdrop)
|
||||
.clip(RoundedCornerShape(trackHeight / 2))
|
||||
.background(animatedTrackColor.value)
|
||||
.width(trackWidth)
|
||||
.height(trackHeight)
|
||||
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.graphicsLayer {
|
||||
translationX = animatedFraction.value * (trackWidthPx.floatValue - with(density) { thumbWidth.toPx() + 4.dp.toPx() })
|
||||
}
|
||||
.then(if (enabled) Modifier.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
if (trackWidthPx.floatValue > 0f) {
|
||||
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
||||
animationScope.launch {
|
||||
animatedFraction.snapTo(newFraction)
|
||||
}
|
||||
totalDrag.floatValue += kotlin.math.abs(delta)
|
||||
val newChecked = newFraction >= 0.5f
|
||||
if (newChecked != checked) {
|
||||
onCheckedChange(newChecked)
|
||||
}
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
totalDrag.floatValue = 0f
|
||||
animationScope.launch {
|
||||
progressAnimation.animateTo(1f, progressAnimationSpec)
|
||||
}
|
||||
},
|
||||
onDragStopped = {
|
||||
animationScope.launch {
|
||||
if (totalDrag.floatValue < tapThreshold) {
|
||||
val newChecked = !checked
|
||||
onCheckedChange(newChecked)
|
||||
val snappedFraction = if (newChecked) 1f else 0f
|
||||
coroutineScope {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
|
||||
}
|
||||
} else {
|
||||
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
|
||||
onCheckedChange(snappedFraction >= 0.5f)
|
||||
coroutineScope {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) else Modifier)
|
||||
.drawBackdrop(
|
||||
rememberCombinedBackdrop(backdrop, switchBackdrop),
|
||||
{ RoundedCornerShape(thumbHeight / 2) },
|
||||
highlight = {
|
||||
val progress = progressAnimation.value
|
||||
Highlight.Ambient.copy(
|
||||
alpha = progress
|
||||
)
|
||||
},
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 4f.dp,
|
||||
color = Color.Black.copy(0.05f)
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1.5f, progress)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
},
|
||||
onDrawBackdrop = { drawScope ->
|
||||
drawIntoCanvas { canvas ->
|
||||
canvas.save()
|
||||
canvas.drawRect(
|
||||
left = 0f,
|
||||
top = 0f,
|
||||
right = size.width,
|
||||
bottom = size.height,
|
||||
paint = Paint().apply {
|
||||
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||
}
|
||||
)
|
||||
scale(0.7f) {
|
||||
drawScope()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
|
||||
val shape = RoundedCornerShape(thumbHeight / 2)
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
val innerShadowOffset = 4f.dp.toPx()
|
||||
val innerShadowBlurRadius = 4f.dp.toPx()
|
||||
|
||||
innerShadowLayer.alpha = progress
|
||||
innerShadowLayer.renderEffect =
|
||||
BlurEffect(
|
||||
innerShadowBlurRadius,
|
||||
innerShadowBlurRadius,
|
||||
TileMode.Decal
|
||||
)
|
||||
innerShadowLayer.record {
|
||||
drawOutline(outline, Color.Black.copy(0.2f))
|
||||
translate(0f, innerShadowOffset) {
|
||||
drawOutline(
|
||||
outline,
|
||||
Color.Transparent,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLayer(innerShadowLayer)
|
||||
|
||||
drawRect(Color.White.copy(1f - progress))
|
||||
},
|
||||
effects = {
|
||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
||||
}
|
||||
)
|
||||
.width(thumbWidth)
|
||||
.height(thumbHeight)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Composable
|
||||
fun StyledSwitchPreview() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.width(100.dp)
|
||||
.height(150.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val checked = remember { mutableStateOf(true) }
|
||||
StyledSwitch(
|
||||
checked = checked.value,
|
||||
onCheckedChange = {
|
||||
checked.value = it
|
||||
},
|
||||
enabled = true,
|
||||
)
|
||||
// LaunchedEffect(Unit) {
|
||||
// delay(1000)
|
||||
// checked.value = false
|
||||
// delay(1000)
|
||||
// checked.value = true
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
var checked by checkedState
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
fun cb() {
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(16.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val service = ServiceManager.getService() ?: return
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val checkedValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == controlCommandIdentifier
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
fun cb() {
|
||||
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == controlCommandIdentifier.value) {
|
||||
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
|
||||
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(16.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledToggle(
|
||||
title: String? = null,
|
||||
label: String,
|
||||
description: String? = null,
|
||||
attHandle: ATTHandles,
|
||||
independent: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
sharedPreferenceKey: String? = null,
|
||||
sharedPreferences: SharedPreferences? = null,
|
||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt()
|
||||
var checked by remember { mutableStateOf(checkedValue !=0) }
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
attManager.enableNotifications(attHandle)
|
||||
|
||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
||||
}
|
||||
|
||||
fun cb() {
|
||||
if (sharedPreferences != null) {
|
||||
if (sharedPreferenceKey == null) {
|
||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
||||
return
|
||||
}
|
||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
||||
}
|
||||
onCheckedChange?.invoke(checked)
|
||||
}
|
||||
|
||||
LaunchedEffect(checked) {
|
||||
if (attManager.socket?.isConnected != true) return@LaunchedEffect
|
||||
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
if (value.isNotEmpty()) {
|
||||
checked = value[0].toInt() != 0
|
||||
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
|
||||
} else {
|
||||
Log.w("StyledToggle", "Empty value in notification for $label")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
attManager.registerListener(attHandle, listener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
attManager.unregisterListener(attHandle, listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(4.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor =
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (description != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(16.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
if (enabled) {
|
||||
checked = !checked
|
||||
cb()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = {
|
||||
if (enabled) {
|
||||
checked = it
|
||||
cb()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StyledTogglePreview() {
|
||||
val context = LocalContext.current
|
||||
val sharedPrefs = context.getSharedPreferences("preview", 0)
|
||||
StyledToggle(
|
||||
label = "Example Toggle",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
sharedPreferences = sharedPrefs
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
fun VerticalVolumeSlider(
|
||||
displayFraction: Float,
|
||||
maxVolume: Int,
|
||||
onVolumeChange: (Int) -> Unit,
|
||||
initialFraction: Float,
|
||||
onDragStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
baseSliderHeight: Dp = 400.dp,
|
||||
baseSliderWidth: Dp = 145.dp,
|
||||
baseCornerRadius: Dp = 45.dp,
|
||||
maxStretchFactor: Float = 1.15f,
|
||||
minCompressionFactor: Float = 0.875f,
|
||||
stretchSensitivity: Float = 1.0f,
|
||||
compressionSensitivity: Float = 1.0f,
|
||||
cornerRadiusChangeFactor: Float = 0.2f,
|
||||
directionalStretchRatio: Float = 0.75f
|
||||
) {
|
||||
val trackColor = Color(0x593C3C3E)
|
||||
val progressColor = Color.White
|
||||
|
||||
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
|
||||
var overscrollAmount by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = dragFraction.coerceIn(0f, 1f),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "ProgressAnimation"
|
||||
)
|
||||
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = overscrollAmount,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "OverscrollAnimation"
|
||||
)
|
||||
|
||||
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
|
||||
|
||||
val stretchMultiplier = stretchSensitivity
|
||||
val compressionMultiplier = compressionSensitivity
|
||||
|
||||
val overscrollDirection = sign(animatedOverscroll)
|
||||
|
||||
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
|
||||
|
||||
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
|
||||
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
|
||||
(-overscrollDirection * asymmetricOffset.value).dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
|
||||
val heightStretch = baseSliderHeight + totalStretchAmount
|
||||
|
||||
val widthCompression = baseSliderWidth * max(
|
||||
minCompressionFactor,
|
||||
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
|
||||
)
|
||||
|
||||
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(heightStretch)
|
||||
.width(widthCompression)
|
||||
.offset(y = offsetY)
|
||||
.clip(RoundedCornerShape(dynamicCornerRadius))
|
||||
.background(trackColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
|
||||
dragFraction = newFraction
|
||||
rawDragPosition = newFraction
|
||||
overscrollAmount = 0f
|
||||
|
||||
val newVolume = (newFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
rawDragPosition -= (delta / baseHeightPx)
|
||||
|
||||
dragFraction = rawDragPosition.coerceIn(0f, 1f)
|
||||
|
||||
overscrollAmount = when {
|
||||
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
|
||||
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val newVolume = (dragFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
},
|
||||
onDragStarted = {
|
||||
isDragging = true
|
||||
dragFraction = displayFraction
|
||||
rawDragPosition = displayFraction
|
||||
overscrollAmount = 0f
|
||||
onDragStateChange(true)
|
||||
},
|
||||
onDragStopped = {
|
||||
isDragging = false
|
||||
overscrollAmount = 0f
|
||||
rawDragPosition = dragFraction
|
||||
onDragStateChange(false)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(animatedProgress)
|
||||
.background(progressColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.constants
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
const val LEFT = 4
|
||||
const val RIGHT = 2
|
||||
const val CASE = 8
|
||||
}
|
||||
|
||||
object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
|
||||
fun getComponentName(): String? {
|
||||
return when (component) {
|
||||
BatteryComponent.LEFT -> "LEFT"
|
||||
BatteryComponent.RIGHT -> "RIGHT"
|
||||
BatteryComponent.CASE -> "CASE"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusName(): String? {
|
||||
return when (status) {
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class NoiseControlMode {
|
||||
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
|
||||
}
|
||||
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
|
||||
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = listOf(data[6], data[7])
|
||||
}
|
||||
|
||||
fun isEarDetectionData(data: ByteArray): Boolean {
|
||||
if (data.size != 8) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ANC {
|
||||
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
|
||||
|
||||
var status: Int = 1
|
||||
private set
|
||||
|
||||
fun isANCData(data: ByteArray): Boolean {
|
||||
if (data.size != 11) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
when (data.size) {
|
||||
// if the whole packet is given
|
||||
11 -> {
|
||||
status = data[7].toInt()
|
||||
}
|
||||
// if only the data is given
|
||||
1 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
// if the value of control command is given
|
||||
4 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
else -> {
|
||||
Log.d("ANC", "Invalid ANC data size: ${data.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
4 -> "ADAPTIVE"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BatteryNotification {
|
||||
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
|
||||
|
||||
fun isBatteryData(data: ByteArray): Boolean {
|
||||
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
|
||||
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
if (data.size != 22) {
|
||||
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
|
||||
return false
|
||||
}
|
||||
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
|
||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||
}
|
||||
|
||||
fun setBatteryDirect(
|
||||
leftLevel: Int,
|
||||
leftCharging: Boolean,
|
||||
rightLevel: Int,
|
||||
rightCharging: Boolean,
|
||||
caseLevel: Int,
|
||||
caseCharging: Boolean
|
||||
) {
|
||||
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
}
|
||||
|
||||
fun setBattery(data: ByteArray) {
|
||||
if (data.size != 22) {
|
||||
return
|
||||
}
|
||||
// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(first.component, first.level, data[10].toInt())
|
||||
// } else {
|
||||
// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
|
||||
// }
|
||||
// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(second.component, second.level, data[15].toInt())
|
||||
// } else {
|
||||
// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
|
||||
// }
|
||||
// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
|
||||
// Battery(case.component, case.level, data[20].toInt())
|
||||
// } else {
|
||||
// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
|
||||
// }
|
||||
// sometimes it shows battery as -1%, just skip all that and set it normally
|
||||
first = Battery(
|
||||
data[7].toInt(), data[9].toInt(), data[10].toInt()
|
||||
)
|
||||
second = Battery(
|
||||
data[12].toInt(), data[14].toInt(), data[15].toInt()
|
||||
)
|
||||
case = Battery(
|
||||
data[17].toInt(), data[19].toInt(), data[20].toInt()
|
||||
)
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
val prefixPattern = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00
|
||||
)
|
||||
|
||||
for (i in prefixPattern.indices) {
|
||||
if (data[i] != prefixPattern[i]) return false
|
||||
}
|
||||
|
||||
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
|
||||
|
||||
if (data[11] != 0x00.toByte()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.constants
|
||||
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
|
||||
enum class StemAction {
|
||||
PLAY_PAUSE,
|
||||
PREVIOUS_TRACK,
|
||||
NEXT_TRACK,
|
||||
DIGITAL_ASSISTANT,
|
||||
CYCLE_NOISE_CONTROL_MODES;
|
||||
companion object {
|
||||
fun fromString(action: String): StemAction? {
|
||||
return entries.find { it.name == action }
|
||||
}
|
||||
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
||||
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import me.kavishdevar.librepods.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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledDropdown
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var phoneMediaDebounceJob: Job? = null
|
||||
private var toneVolumeDebounceJob: Job? = null
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AccessibilitySettingsScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val isSdpOffsetAvailable =
|
||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
||||
|
||||
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 capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
|
||||
|
||||
val hearingAidEnabled = remember { mutableStateOf(
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
|
||||
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
|
||||
) }
|
||||
|
||||
val hearingAidListener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.accessibility)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to "Default",
|
||||
1.toByte() to "Slower",
|
||||
2.toByte() to "Slowest"
|
||||
)
|
||||
val selectedPressSpeedValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
selectedPressSpeedListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
selectedPressSpeedListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to "Default",
|
||||
1.toByte() to "Slower",
|
||||
2.toByte() to "Slowest"
|
||||
)
|
||||
val selectedPressAndHoldDurationValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedPressAndHoldDuration =
|
||||
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
selectedPressAndHoldDurationListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
selectedPressAndHoldDurationListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to "Default",
|
||||
2.toByte() to "Longer",
|
||||
3.toByte() to "Longest"
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedVolumeSwipeSpeed =
|
||||
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
selectedVolumeSwipeSpeedListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
selectedVolumeSwipeSpeedListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
val manager = ServiceManager.getService()?.aacpManager
|
||||
if (manager == null) {
|
||||
Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val toneVolumeValue = remember { mutableFloatStateOf(
|
||||
aacpManager?.controlCommandStatusList?.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
|
||||
) }
|
||||
LaunchedEffect(toneVolumeValue.floatValue) {
|
||||
toneVolumeDebounceJob?.cancel()
|
||||
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
val manager = ServiceManager.getService()?.aacpManager
|
||||
if (manager == null) {
|
||||
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
manager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
||||
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending tone volume: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description),
|
||||
options = pressSpeedOptions.values.toList(),
|
||||
selectedOption = selectedPressSpeed?: "Default",
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressSpeed = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_and_hold_duration),
|
||||
description = stringResource(R.string.press_and_hold_duration_description),
|
||||
options = pressAndHoldDurationOptions.values.toList(),
|
||||
selectedOption = selectedPressAndHoldDuration?: "Default",
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressAndHoldDuration = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
||||
independent = true,
|
||||
)
|
||||
|
||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
||||
NavigationButton(
|
||||
to = "transparency_customization",
|
||||
name = stringResource(R.string.customize_transparency_mode),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
mutableFloatState = toneVolumeValue,
|
||||
onValueChange = {
|
||||
toneVolumeValue.floatValue = it
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startIcon = "\uDBC0\uDEA1",
|
||||
endIcon = "\uDBC0\uDEA9",
|
||||
independent = true
|
||||
)
|
||||
|
||||
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
||||
)
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description),
|
||||
options = volumeSwipeSpeedOptions.values.toList(),
|
||||
selectedOption = selectedVolumeSwipeSpeed?: "Default",
|
||||
onOptionSelected = { newValue ->
|
||||
selectedVolumeSwipeSpeed = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 1.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
||||
// Text(
|
||||
// text = stringResource(R.string.apply_eq_to),
|
||||
// style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = textColor.copy(alpha = 0.6f),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
// ),
|
||||
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .padding(vertical = 0.dp)
|
||||
// ) {
|
||||
// val darkModeLocal = isSystemInDarkTheme()
|
||||
//
|
||||
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
// var phoneBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val phoneAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = phoneBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(phoneAnimatedBackgroundColor, phoneShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// tryAwaitRelease()
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// phoneEQEnabled.value = !phoneEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.phone),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = phoneEQEnabled.value,
|
||||
// onCheckedChange = { phoneEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888)
|
||||
// )
|
||||
//
|
||||
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
// var mediaBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val mediaAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = mediaBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(mediaAnimatedBackgroundColor, mediaShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// tryAwaitRelease()
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// mediaEQEnabled.value = !mediaEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.media),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = mediaEQEnabled.value,
|
||||
// onCheckedChange = { mediaEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// EQ Settings. Don't seem to have an effect?
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .padding(12.dp),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// for (i in 0 until 8) {
|
||||
// val eqPhoneValue =
|
||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
||||
// Row(
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(38.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// )
|
||||
|
||||
// Slider(
|
||||
// value = eqPhoneValue.floatValue,
|
||||
// onValueChange = { newVal ->
|
||||
// eqPhoneValue.floatValue = newVal
|
||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
||||
// newEQ[i] = eqPhoneValue.floatValue
|
||||
// phoneMediaEQ.value = newEQ
|
||||
// },
|
||||
// valueRange = 0f..100f,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(0.9f)
|
||||
// .height(36.dp),
|
||||
// colors = SliderDefaults.colors(
|
||||
// thumbColor = thumbColor,
|
||||
// activeTrackColor = activeTrackColor,
|
||||
// inactiveTrackColor = trackColor
|
||||
// ),
|
||||
// thumb = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(24.dp)
|
||||
// .shadow(4.dp, CircleShape)
|
||||
// .background(thumbColor, CircleShape)
|
||||
// )
|
||||
// },
|
||||
// track = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(12.dp),
|
||||
// contentAlignment = Alignment.CenterStart
|
||||
// )
|
||||
// {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(4.dp)
|
||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
|
||||
// .height(4.dp)
|
||||
// .background(activeTrackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// Text(
|
||||
// text = stringResource(R.string.band_label, i + 1),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(top = 4.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
private fun DropdownMenuComponent(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
textColor: Color,
|
||||
hazeState: HazeState,
|
||||
description: String? = null,
|
||||
independent: Boolean = true
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()){
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (independent) {
|
||||
if (description != null) {
|
||||
Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(vertical = 8.dp)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.background(
|
||||
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent,
|
||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||
)
|
||||
then(
|
||||
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
|
||||
)
|
||||
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
|
||||
){
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (expanded) {
|
||||
expanded = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > 250L) {
|
||||
touchOffset = offset
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!expanded && now - lastDismissTime > 250L) {
|
||||
expanded = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndex = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
){
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (!independent && description != null){
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedOption,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = options,
|
||||
selectedOption = selectedOption,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (independent && description != null){
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
){
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AdaptiveStrengthScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
val service = ServiceManager.getService()!!
|
||||
|
||||
LaunchedEffect(sliderValue) {
|
||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
|
||||
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
|
||||
sliderValue.floatValue = (100 - it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
listener
|
||||
)
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
listener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_adaptive_audio)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
mutableFloatState = sliderValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
debounceJob?.cancel()
|
||||
debounceJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(300)
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
||||
(100 - it).toInt()
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.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.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.AboutCard
|
||||
import me.kavishdevar.librepods.composables.AudioSettings
|
||||
import me.kavishdevar.librepods.composables.BatteryView
|
||||
import me.kavishdevar.librepods.composables.CallControlSettings
|
||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.composables.ConnectionSettings
|
||||
import me.kavishdevar.librepods.composables.HearingHealthSettings
|
||||
import me.kavishdevar.librepods.composables.MicrophoneSettings
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||
import me.kavishdevar.librepods.composables.StyledButton
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
isLocallyConnected = service.isConnectedLocally
|
||||
}
|
||||
|
||||
val nameChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "name") {
|
||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleRemoteConnection(connected: Boolean) {
|
||||
isRemotelyConnected = connected
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val connectionReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(true)
|
||||
}
|
||||
}
|
||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(false)
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = true
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = false
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(connectionReceiver, filter)
|
||||
}
|
||||
onDispose {
|
||||
try {
|
||||
context.unregisterReceiver(connectionReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
service.let {
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
||||
})
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
|
||||
putExtra("data", it.getANC())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
||||
|
||||
val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
|
||||
|
||||
StyledScaffold(
|
||||
title = deviceName.text,
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = { navController.navigate("app_settings") },
|
||||
icon = "",
|
||||
darkMode = darkMode,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
snackbarHostState = snackbarHostState
|
||||
) { spacerHeight, hazeState ->
|
||||
hazeStateS.value = hazeState
|
||||
if (isLocallyConnected || isRemotelyConnected) {
|
||||
val instance = service.airpodsInstance
|
||||
if (instance == null) {
|
||||
Text("Error: AirPods instance is null")
|
||||
return@StyledScaffold
|
||||
}
|
||||
val capabilities = instance.model.capabilities
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
||||
item(key = "battery") {
|
||||
BatteryView(service = service)
|
||||
}
|
||||
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
|
||||
item(key = "name") {
|
||||
NavigationButton(
|
||||
to = "rename",
|
||||
name = stringResource(R.string.name),
|
||||
currentState = deviceName.text,
|
||||
navController = navController,
|
||||
independent = true
|
||||
)
|
||||
}
|
||||
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
|
||||
if (actAsAppleDeviceHookEnabled) {
|
||||
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
item(key = "hearing_health") {
|
||||
HearingHealthSettings(navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
||||
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
||||
}
|
||||
|
||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
||||
}
|
||||
|
||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
||||
|
||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
|
||||
}
|
||||
|
||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "audio") { AudioSettings(navController = navController) }
|
||||
|
||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "connection") { ConnectionSettings() }
|
||||
|
||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
||||
|
||||
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "sleep_detection") {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.sleep_detection),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
|
||||
}
|
||||
|
||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
||||
|
||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "off_listening") {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.off_listening_mode),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
description = stringResource(R.string.off_listening_mode_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
item(key = "about") { AboutCard(navController = navController) }
|
||||
|
||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
||||
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
else {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBackdrop(
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
exportedBackdrop = backdrop,
|
||||
shape = { RoundedCornerShape(0.dp) },
|
||||
highlight = {
|
||||
Highlight.Ambient.copy(alpha = 0f)
|
||||
}
|
||||
)
|
||||
.hazeSource(hazeState)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.airpods_not_connected),
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.airpods_not_connected_description),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
StyledButton(
|
||||
onClick = { navController.navigate("troubleshooting") },
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = "Troubleshoot Connection",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StyledButton(
|
||||
onClick = {
|
||||
service.reconnectFromSavedMac()
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reconnect_to_last_device),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = stringResource(R.string.support_librepods),
|
||||
message = stringResource(R.string.support_dialog_description),
|
||||
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
|
||||
dismissText = stringResource(R.string.never_show_again),
|
||||
onConfirm = {
|
||||
val browserIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://github.com/sponsors/kavishdevar".toUri()
|
||||
)
|
||||
context.startActivity(browserIntent)
|
||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
||||
},
|
||||
onDismiss = {
|
||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
||||
},
|
||||
hazeState = hazeStateS.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AirPodsSettingsScreenPreview() {
|
||||
Column (
|
||||
modifier = Modifier.height(2000.dp)
|
||||
) {
|
||||
LibrePodsTheme (
|
||||
darkTheme = true
|
||||
) {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(navController: NavController) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val showResetDialog = remember { mutableStateOf(false) }
|
||||
val showIrkDialog = remember { mutableStateOf(false) }
|
||||
val showEncKeyDialog = remember { mutableStateOf(false) }
|
||||
val showCameraDialog = remember { mutableStateOf(false) }
|
||||
val irkValue = remember { mutableStateOf("") }
|
||||
val encKeyValue = remember { mutableStateOf("") }
|
||||
val cameraPackageValue = remember { mutableStateOf("") }
|
||||
val irkError = remember { mutableStateOf<String?>(null) }
|
||||
val encKeyError = remember { mutableStateOf<String?>(null) }
|
||||
val cameraPackageError = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||
val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null)
|
||||
|
||||
if (savedIrk != null) {
|
||||
try {
|
||||
val decoded = Base64.decode(savedIrk)
|
||||
irkValue.value = decoded.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
irkValue.value = ""
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedEncKey != null) {
|
||||
try {
|
||||
val decoded = Base64.decode(savedEncKey)
|
||||
encKeyValue.value = decoded.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
encKeyValue.value = ""
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
if (savedCameraPackage != null) {
|
||||
cameraPackageValue.value = savedCameraPackage
|
||||
}
|
||||
}
|
||||
|
||||
val showPhoneBatteryInWidget = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
||||
}
|
||||
val conversationalAwarenessPauseMusicEnabled = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
|
||||
}
|
||||
val relativeConversationalAwarenessVolumeEnabled = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
|
||||
}
|
||||
val openDialogForControlling = remember {
|
||||
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
|
||||
}
|
||||
val disconnectWhenNotWearing = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
||||
}
|
||||
|
||||
val takeoverWhenDisconnected = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
|
||||
}
|
||||
val takeoverWhenIdle = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
|
||||
}
|
||||
val takeoverWhenMusic = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
|
||||
}
|
||||
val takeoverWhenCall = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
|
||||
}
|
||||
|
||||
val takeoverWhenRingingCall = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
|
||||
}
|
||||
val takeoverWhenMediaStart = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
||||
}
|
||||
|
||||
val useAlternateHeadTrackingPackets = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
||||
}
|
||||
|
||||
fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
val isProcessingSdp = remember { mutableStateOf(false) }
|
||||
val actAsAppleDevice = remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = isProcessingSdp.value) {}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.app_settings)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(state = hazeState)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.widget),
|
||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||
checkedState = showPhoneBatteryInWidget,
|
||||
sharedPreferenceKey = "show_phone_battery_in_widget",
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
||||
conversationalAwarenessPauseMusicEnabled.value = enabled
|
||||
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)}
|
||||
}
|
||||
|
||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
||||
relativeConversationalAwarenessVolumeEnabled.value = enabled
|
||||
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checkedState = conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = { updateConversationalAwarenessPauseMusic(it) },
|
||||
independent = false
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
checkedState = relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) },
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) }
|
||||
LaunchedEffect(conversationalAwarenessVolume.floatValue) {
|
||||
sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) }
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
mutableFloatState = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue },
|
||||
independent = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "",
|
||||
title = stringResource(R.string.camera_control),
|
||||
name = stringResource(R.string.set_custom_camera_package),
|
||||
navController = navController,
|
||||
onClick = { showCameraDialog.value = true },
|
||||
independent = true,
|
||||
description = stringResource(R.string.camera_control_app_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.quick_settings_tile),
|
||||
label = stringResource(R.string.open_dialog_for_controlling),
|
||||
description = stringResource(R.string.open_dialog_for_controlling_description),
|
||||
checkedState = openDialogForControlling,
|
||||
onCheckedChange = {
|
||||
openDialogForControlling.value = it
|
||||
sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "activity") }
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checkedState = disconnectWhenNotWearing,
|
||||
sharedPreferenceKey = "disconnect_when_not_wearing",
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_airpods_state),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_disconnected),
|
||||
description = stringResource(R.string.takeover_disconnected_desc),
|
||||
checkedState = takeoverWhenDisconnected,
|
||||
onCheckedChange = {
|
||||
takeoverWhenDisconnected.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_idle),
|
||||
description = stringResource(R.string.takeover_idle_desc),
|
||||
checkedState = takeoverWhenIdle,
|
||||
onCheckedChange = {
|
||||
takeoverWhenIdle.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_idle", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_music),
|
||||
description = stringResource(R.string.takeover_music_desc),
|
||||
checkedState = takeoverWhenMusic,
|
||||
onCheckedChange = {
|
||||
takeoverWhenMusic.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_music", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_call),
|
||||
description = stringResource(R.string.takeover_call_desc),
|
||||
checkedState = takeoverWhenCall,
|
||||
onCheckedChange = {
|
||||
takeoverWhenCall.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_call", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_phone_state),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
){
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_ringing_call),
|
||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||
checkedState = takeoverWhenRingingCall,
|
||||
onCheckedChange = {
|
||||
takeoverWhenRingingCall.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_media_start),
|
||||
description = stringResource(R.string.takeover_media_start_desc),
|
||||
checkedState = takeoverWhenMediaStart,
|
||||
onCheckedChange = {
|
||||
takeoverWhenMediaStart.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_media_start", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_options),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable (
|
||||
onClick = { showIrkDialog.value = true },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.set_identity_resolving_key),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.set_identity_resolving_key_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable (
|
||||
onClick = { showEncKeyDialog.value = true },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.set_encryption_key),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.set_encryption_key_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checkedState = useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = {
|
||||
useAlternateHeadTrackingPackets.value = it
|
||||
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)}
|
||||
},
|
||||
independent = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "troubleshooting",
|
||||
name = stringResource(R.string.troubleshooting),
|
||||
navController = navController,
|
||||
independent = true,
|
||||
description = stringResource(R.string.troubleshooting_description)
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable()
|
||||
}
|
||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.act_as_an_apple_device),
|
||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||
checkedState = actAsAppleDevice,
|
||||
onCheckedChange = {
|
||||
actAsAppleDevice.value = it
|
||||
isProcessingSdp.value = true
|
||||
coroutineScope.launch {
|
||||
if (it) {
|
||||
val radareOffsetFinder = RadareOffsetFinder(context)
|
||||
val success = radareOffsetFinder.findSdpOffset()
|
||||
if (success) {
|
||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RadareOffsetFinder.clearSdpOffset()
|
||||
}
|
||||
isProcessingSdp.value = false
|
||||
}
|
||||
},
|
||||
independent = true,
|
||||
enabled = !isProcessingSdp.value
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { showResetDialog.value = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reset",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reset_hook_offset),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "open_source_licenses",
|
||||
name = stringResource(R.string.open_source_licenses),
|
||||
navController = navController,
|
||||
independent = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
if (showResetDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
"Reset Hook Offset",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.reset_hook_offset_description),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.hook_offset_reset_success)
|
||||
val failureText = stringResource(R.string.hook_offset_reset_failure)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (RadareOffsetFinder.clearHookOffsets()) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
successText,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
navController.navigate("onboarding") {
|
||||
popUpTo("settings") { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
failureText,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
showResetDialog.value = false
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.reset),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showResetDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showIrkDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showIrkDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.set_identity_resolving_key),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_irk_hex),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = irkValue.value,
|
||||
onValueChange = {
|
||||
irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||
irkError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = irkError.value != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||
),
|
||||
supportingText = {
|
||||
if (irkError.value != null) {
|
||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.irk_hex_value)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.irk_set_success)
|
||||
val errorText = stringResource(R.string.error_converting_hex)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!validateHexInput(irkValue.value)) {
|
||||
irkError.value = "Must be exactly 32 hex characters"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
try {
|
||||
val hexBytes = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = irkValue.value.substring(i * 2, i * 2 + 2)
|
||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
|
||||
val base64Value = Base64.encode(hexBytes)
|
||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
|
||||
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showIrkDialog.value = false
|
||||
} catch (e: Exception) {
|
||||
irkError.value = errorText + " " + (e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showIrkDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showEncKeyDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEncKeyDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.set_encryption_key),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_enc_key_hex),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = encKeyValue.value,
|
||||
onValueChange = {
|
||||
encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||
encKeyError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = encKeyError.value != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||
),
|
||||
supportingText = {
|
||||
if (encKeyError.value != null) {
|
||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.enc_key_hex_value)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.encryption_key_set_success)
|
||||
val errorText = stringResource(R.string.error_converting_hex)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!validateHexInput(encKeyValue.value)) {
|
||||
encKeyError.value = "Must be exactly 32 hex characters"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
try {
|
||||
val hexBytes = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2)
|
||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
|
||||
val base64Value = Base64.encode(hexBytes)
|
||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
|
||||
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showEncKeyDialog.value = false
|
||||
} catch (e: Exception) {
|
||||
encKeyError.value = errorText + " " + (e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showEncKeyDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCameraDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCameraDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.set_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = cameraPackageValue.value,
|
||||
onValueChange = {
|
||||
cameraPackageValue.value = it
|
||||
cameraPackageError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cameraPackageError.value != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||
),
|
||||
supportingText = {
|
||||
if (cameraPackageError.value != null) {
|
||||
Text(cameraPackageError.value!!, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.custom_camera_package)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.custom_camera_package_set_success)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (cameraPackageValue.value.isBlank()) {
|
||||
sharedPreferences.edit { remove("custom_camera_package") }
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showCameraDialog.value = false
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) }
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showCameraDialog.value = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showCameraDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.core.content.edit
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.SelectItem
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.services.AppListenerService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun CameraControlScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val service = ServiceManager.getService()!!
|
||||
var currentCameraAction by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||
return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className }
|
||||
}
|
||||
|
||||
val cameraOptions = listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.off),
|
||||
selected = currentCameraAction == null,
|
||||
onClick = {
|
||||
sharedPreferences.edit { remove("camera_action") }
|
||||
currentCameraAction = null
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.press_once),
|
||||
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||
onClick = {
|
||||
if (!isAppListenerServiceEnabled(context)) {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
} else {
|
||||
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
|
||||
currentCameraAction = StemPressType.SINGLE_PRESS
|
||||
}
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.press_and_hold_airpods),
|
||||
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||
onClick = {
|
||||
if (!isAppListenerServiceEnabled(context)) {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
} else {
|
||||
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
|
||||
currentCameraAction = StemPressType.LONG_PRESS
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.camera_control)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSelectList(items = cameraOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.filled.Send
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class PacketInfo(
|
||||
val type: String,
|
||||
val description: String,
|
||||
val rawData: String,
|
||||
val parsedData: Map<String, String> = emptyMap(),
|
||||
val isUnknown: Boolean = false
|
||||
)
|
||||
|
||||
fun parsePacket(message: String): PacketInfo {
|
||||
val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9)
|
||||
val bytes = rawData.split(" ").mapNotNull {
|
||||
it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte()
|
||||
}.toByteArray()
|
||||
|
||||
val airPodsService = ServiceManager.getService()
|
||||
if (airPodsService != null) {
|
||||
return when {
|
||||
message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData)
|
||||
airPodsService.batteryNotification.isBatteryData(bytes) -> {
|
||||
val batteryInfo = mutableMapOf<String, String>()
|
||||
airPodsService.batteryNotification.setBattery(bytes)
|
||||
val batteries = airPodsService.batteryNotification.getBattery()
|
||||
val batteryInfoString = batteries.joinToString(", ") { battery ->
|
||||
"${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
batteries.forEach { battery ->
|
||||
if (battery.status != BatteryStatus.DISCONNECTED) {
|
||||
batteryInfo[battery.getComponentName() ?: "Unknown"] =
|
||||
"${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Battery",
|
||||
batteryInfoString,
|
||||
rawData,
|
||||
batteryInfo
|
||||
)
|
||||
}
|
||||
airPodsService.ancNotification.isANCData(bytes) -> {
|
||||
airPodsService.ancNotification.setStatus(bytes)
|
||||
val mode = when (airPodsService.ancNotification.status) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Noise Control",
|
||||
"Mode: $mode",
|
||||
rawData,
|
||||
mapOf("Mode" to mode)
|
||||
)
|
||||
}
|
||||
airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> {
|
||||
airPodsService.earDetectionNotification.setStatus(bytes)
|
||||
val status = airPodsService.earDetectionNotification.status
|
||||
val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear"
|
||||
val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear"
|
||||
|
||||
PacketInfo(
|
||||
"Ear Detection",
|
||||
"Primary: $primaryStatus, Secondary: $secondaryStatus",
|
||||
rawData,
|
||||
mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus)
|
||||
)
|
||||
}
|
||||
airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> {
|
||||
airPodsService.conversationAwarenessNotification.setData(bytes)
|
||||
val statusMap = mapOf(
|
||||
1.toByte() to "Started speaking",
|
||||
2.toByte() to "Speaking",
|
||||
8.toByte() to "Stopped speaking",
|
||||
9.toByte() to "Not speaking"
|
||||
)
|
||||
val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?:
|
||||
"Unknown (${airPodsService.conversationAwarenessNotification.status})"
|
||||
|
||||
PacketInfo(
|
||||
"Conversation Awareness",
|
||||
"Status: $status",
|
||||
rawData,
|
||||
mapOf("Status" to status)
|
||||
)
|
||||
}
|
||||
isHeadTrackingData(bytes) -> {
|
||||
val horizontal = if (bytes.size >= 53)
|
||||
"${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown"
|
||||
val vertical = if (bytes.size >= 55)
|
||||
"${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown"
|
||||
|
||||
PacketInfo(
|
||||
"Head Tracking",
|
||||
"Position data",
|
||||
rawData,
|
||||
mapOf("Horizontal" to horizontal, "Vertical" to vertical)
|
||||
)
|
||||
}
|
||||
else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
} else {
|
||||
return if (message.startsWith("Sent")) {
|
||||
parseOutgoingPacket(bytes, rawData)
|
||||
} else {
|
||||
PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
|
||||
if (bytes.size < 7) {
|
||||
return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
|
||||
return when {
|
||||
bytes.size >= 16 &&
|
||||
bytes[0] == 0x00.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() -> {
|
||||
PacketInfo("Handshake", "Initial handshake with AirPods", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x0d.toByte() -> {
|
||||
val mode = when (bytes[7].toInt()) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x28.toByte() -> {
|
||||
val mode = if (bytes[7].toInt() == 1) "On" else "Off"
|
||||
PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size > 10 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x17.toByte() -> {
|
||||
val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop"
|
||||
PacketInfo("Head Tracking", "$action head tracking", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x1A.toByte() -> {
|
||||
PacketInfo("Long Press Config", "Change long press modes", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x4d.toByte() -> {
|
||||
PacketInfo("Feature Request", "Set specific features", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x0f.toByte() -> {
|
||||
PacketInfo("Notifications", "Request notifications", rawData)
|
||||
}
|
||||
|
||||
else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun DebugScreen(navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val airPodsService = remember { ServiceManager.getService() }
|
||||
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
|
||||
|
||||
val refreshTrigger = remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(refreshTrigger.intValue) {
|
||||
while(true) {
|
||||
delay(1000)
|
||||
refreshTrigger.intValue += 1
|
||||
}
|
||||
}
|
||||
|
||||
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
fun copyToClipboard(text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Packet Data", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
LaunchedEffect(packetLogs.size, refreshTrigger.intValue) {
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(packetLogs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = "Debug",
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
airPodsService?.clearLogs()
|
||||
expandedItems.value = emptySet()
|
||||
},
|
||||
icon = "",
|
||||
darkMode = isDarkTheme,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.navigationBarsPadding()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = {
|
||||
items(packetLogs.size) { index ->
|
||||
val message = packetLogs.elementAt(index)
|
||||
val isSent = message.startsWith("Sent")
|
||||
val isExpanded = expandedItems.value.contains(index)
|
||||
val packetInfo = parsePacket(message)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
expandedItems.value = if (isExpanded) {
|
||||
expandedItems.value - index
|
||||
} else {
|
||||
expandedItems.value + index
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
copyToClipboard(packetInfo.rawData)
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isSent) "" else "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSent) Color(0xFF4CD964) else Color(0xFFFF3B30)
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (packetInfo.isUnknown) {
|
||||
val shortenedData = packetInfo.rawData.take(60) +
|
||||
(if (packetInfo.rawData.length > 60) "..." else "")
|
||||
shortenedData
|
||||
} else {
|
||||
"${packetInfo.type}: ${packetInfo.description}"
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
)
|
||||
)
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (packetInfo.parsedData.isNotEmpty()) {
|
||||
packetInfo.parsedData.forEach { (key, value) ->
|
||||
Row {
|
||||
Text(
|
||||
text = "$key: ",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Raw: ${packetInfo.rawData}",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val packet = remember { mutableStateOf(TextFieldValue("")) }
|
||||
TextField(
|
||||
value = packet.value,
|
||||
onValueChange = { packet.value = it },
|
||||
label = { Text("Packet") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (packet.value.text.isNotBlank()) {
|
||||
airPodsService?.value?.aacpManager?.sendPacket(
|
||||
packet.value.text
|
||||
.split(" ")
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
)
|
||||
packet.value = TextFieldValue("")
|
||||
focusManager.clearFocus()
|
||||
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
listState.scrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
|
||||
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
|
||||
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledButton
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.HeadTracking
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen(navController: NavController) {
|
||||
DisposableEffect(Unit) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
onDispose {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.head_tracking),
|
||||
actionButtons = listOf(
|
||||
{ scaffoldBackdrop ->
|
||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||
} else {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||
}
|
||||
},
|
||||
icon = if (isActive) "" else "",
|
||||
darkMode = isDarkTheme,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { spacerHeight, hazeState ->
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hazeSource(state = hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledToggle(
|
||||
label = "Head Gestures",
|
||||
sharedPreferences = sharedPreferences,
|
||||
sharedPreferenceKey = "head_gestures",
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
stringResource(R.string.head_gestures_details),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Head Orientation",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
HeadVisualization()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Velocity",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
AccelerationPlot()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||
StyledButton(
|
||||
onClick = {
|
||||
gestureText = gestureTextValue
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier.fillMaxWidth(0.75f),
|
||||
maxScale = 0.05f
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
ParticleText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
onAnimationComplete = {
|
||||
shouldExplode = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private data class Particle(
|
||||
val initialPosition: Offset,
|
||||
val velocity: Offset,
|
||||
var alpha: Float = 1f
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ParticleText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onAnimationComplete: () -> Unit,
|
||||
) {
|
||||
val particles = remember { mutableStateListOf<Particle>() }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
var isAnimating by remember { mutableStateOf(true) }
|
||||
var textVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxWidth()) {
|
||||
val textLayoutResult = textMeasurer.measure(text, style)
|
||||
val textBounds = textLayoutResult.size
|
||||
val centerX = (size.width - textBounds.width) / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
if (textVisible && particles.isEmpty()) {
|
||||
drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
style = style,
|
||||
topLeft = Offset(centerX, centerY - textBounds.height / 2)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.isEmpty()) {
|
||||
val random = Random(System.currentTimeMillis())
|
||||
for (@Suppress("Unused")i in 0..100) {
|
||||
val x = centerX + random.nextFloat() * textBounds.width
|
||||
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
|
||||
val vx = (random.nextFloat() - 0.5f) * 20
|
||||
val vy = (random.nextFloat() - 0.5f) * 20
|
||||
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
|
||||
}
|
||||
}
|
||||
|
||||
particles.forEach { particle ->
|
||||
drawCircle(
|
||||
color = style.color.copy(alpha = particle.alpha),
|
||||
radius = 0.5.dp.toPx(),
|
||||
center = particle.initialPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(text) {
|
||||
while (isAnimating) {
|
||||
delay(16)
|
||||
particles.forEachIndexed { index, particle ->
|
||||
particles[index] = particle.copy(
|
||||
initialPosition = particle.initialPosition + particle.velocity,
|
||||
alpha = (particle.alpha - 0.02f).coerceAtLeast(0f)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.all { it.alpha <= 0f }) {
|
||||
isAnimating = false
|
||||
onAnimationComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadVisualization() {
|
||||
val orientation by HeadTracking.orientation.collectAsState()
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val strokeColor = if (darkTheme) Color.White else Color.Black
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val center = Offset(width / 2, height / 2)
|
||||
val faceRadius = height * 0.35f
|
||||
|
||||
val pitch = Math.toRadians(orientation.pitch.toDouble())
|
||||
val yaw = Math.toRadians(orientation.yaw.toDouble())
|
||||
|
||||
val cosY = cos(yaw).toFloat()
|
||||
val sinY = sin(yaw).toFloat()
|
||||
val cosP = cos(pitch).toFloat()
|
||||
val sinP = sin(pitch).toFloat()
|
||||
|
||||
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val x1 = x * cosY - z * sinY
|
||||
val z1 = x * sinY + z * cosY
|
||||
|
||||
val y2 = y * cosP - z1 * sinP
|
||||
val z2 = y * sinP + z1 * cosP
|
||||
|
||||
return Triple(x1, y2, z2)
|
||||
}
|
||||
|
||||
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val scale = 1f + (z / width)
|
||||
return Pair(center.x + x * scale, center.y + y * scale)
|
||||
}
|
||||
|
||||
val earWidth = height * 0.08f
|
||||
val earHeight = height * 0.2f
|
||||
val earOffsetX = height * 0.4f
|
||||
val earOffsetY = 0f
|
||||
val earZ = 0f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
|
||||
val (earX, earY) = project(rotated)
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
|
||||
size = Size(earWidth, earHeight),
|
||||
cornerRadius = CornerRadius(earWidth/2),
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
val spherePath = Path()
|
||||
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
|
||||
spherePath.moveTo(firstPoint.first, firstPoint.second)
|
||||
|
||||
for (i in 1..32) {
|
||||
val angle = (i * 2 * Math.PI / 32).toFloat()
|
||||
val point = project(rotate3D(Triple(
|
||||
cos(angle) * faceRadius,
|
||||
sin(angle) * faceRadius,
|
||||
0f
|
||||
)))
|
||||
spherePath.lineTo(point.first, point.second)
|
||||
}
|
||||
spherePath.close()
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.3f,
|
||||
center.y - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 1.4f,
|
||||
intArrayOf(
|
||||
backgroundColor.copy(alpha = 1f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.95f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.9f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.8f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||
),
|
||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), paint)
|
||||
|
||||
val highlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 0.9f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.argb(100, 255, 255, 255),
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 0.3f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 30 else 60
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||
|
||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 0.7f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 15 else 30
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||
|
||||
val shadowPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.5f,
|
||||
center.y - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 1.1f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.BLACK
|
||||
),
|
||||
floatArrayOf(0.7f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 40 else 20
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), shadowPaint)
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = spherePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
|
||||
val smileRadius = faceRadius * 0.5f
|
||||
val smileStartAngle = -340f
|
||||
val smileSweepAngle = 140f
|
||||
val smileOffsetY = faceRadius * 0.1f
|
||||
|
||||
val smilePath = Path()
|
||||
for (i in 0..32) {
|
||||
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
|
||||
val x = cos(angle.toFloat()) * smileRadius
|
||||
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
|
||||
|
||||
val rotated = rotate3D(Triple(x, y, 0f))
|
||||
val projected = project(rotated)
|
||||
|
||||
if (i == 0) {
|
||||
smilePath.moveTo(projected.first, projected.second)
|
||||
} else {
|
||||
smilePath.lineTo(projected.first, projected.second)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = smilePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
|
||||
val eyeOffsetX = height * 0.15f
|
||||
val eyeOffsetY = height * 0.1f
|
||||
val eyeLength = height * 0.08f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
|
||||
val (eyeX, eyeY) = project(rotated)
|
||||
drawLine(
|
||||
color = strokeColor,
|
||||
start = Offset(eyeX, eyeY - eyeLength/2),
|
||||
end = Offset(eyeX, eyeY + eyeLength/2),
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
typeface = android.graphics.Typeface.create(
|
||||
"SF Pro",
|
||||
android.graphics.Typeface.NORMAL
|
||||
)
|
||||
}
|
||||
|
||||
val pitch = orientation.pitch.toInt()
|
||||
val yaw = orientation.yaw.toInt()
|
||||
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
|
||||
|
||||
drawText(
|
||||
text,
|
||||
width - 8.dp.toPx(),
|
||||
height - 8.dp.toPx(),
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccelerationPlot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
var maxAbs by remember { mutableFloatStateOf(1000f) }
|
||||
|
||||
LaunchedEffect(acceleration) {
|
||||
points.add(Pair(acceleration.horizontal, acceleration.vertical))
|
||||
if (points.size > maxPoints) {
|
||||
points.removeAt(0)
|
||||
}
|
||||
|
||||
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
|
||||
maxAbs = maxOf(currentMax * 1.2f, 1000f)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xScale = width / maxPoints
|
||||
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
|
||||
val zeroY = height / 2
|
||||
|
||||
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
|
||||
|
||||
for (i in 0..maxPoints step 10) {
|
||||
val x = i * xScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
val gridStep = maxAbs / 4
|
||||
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
|
||||
val y = zeroY - value * yScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(width, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
drawLine(
|
||||
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
|
||||
start = Offset(0f, zeroY),
|
||||
end = Offset(width, zeroY),
|
||||
strokeWidth = 1.5f.dp.toPx()
|
||||
)
|
||||
|
||||
if (points.size > 1) {
|
||||
for (i in 0 until points.size - 1) {
|
||||
val x1 = i * xScale
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFF007AFF),
|
||||
start = Offset(x1, zeroY - points[i].first * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].first * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFFFF3B30),
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
}
|
||||
|
||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||
drawText("0", 30.dp.toPx(), height/2, paint)
|
||||
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
|
||||
}
|
||||
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Preview
|
||||
@Composable
|
||||
fun HeadTrackingScreenPreview() {
|
||||
HeadTrackingScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
||||
isSystemInDarkTheme()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val hazeState = remember { HazeState() }
|
||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.adjustments)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hearingAidEnabled = remember {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
val hearingAidListener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hearingAidATTListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||
balanceSliderValue.floatValue = parsed.balance
|
||||
toneSliderValue.floatValue = parsed.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
eq.value = parsed.leftEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
|
||||
try {
|
||||
if (aacpManager != null) {
|
||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||
val aacpEQ = aacpManager.eqData
|
||||
if (aacpEQ.isNotEmpty()) {
|
||||
eq.value = aacpEQ.copyOf()
|
||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||
phoneEQEnabled.value = aacpManager.eqOnPhone
|
||||
mediaEQEnabled.value = aacpManager.eqOnMedia
|
||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||
} else {
|
||||
Log.d(TAG, "AACPManager EQ data empty")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No AACPManager available")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||
}
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
eq.value = parsedSettings.leftEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = amplificationSliderValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
)
|
||||
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = balanceSliderValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = toneSliderValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checkedState = conversationBoostEnabled,
|
||||
independent = true,
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val initialLoad = remember { mutableStateOf(true) }
|
||||
|
||||
val hearingAidEnabled = remember {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_aid),
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
hazeStateS.value = hazeState
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
val hearingAidListener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(hearingAidEnabled.value) {
|
||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||
showDialog.value = true
|
||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
|
||||
hearingAidEnabled.value = false
|
||||
}
|
||||
initialLoad.value = false
|
||||
}
|
||||
|
||||
// fun onAdjustPhoneChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
// fun onAdjustMediaChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.clip(
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checkedState = hearingAidEnabled,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "hearing_aid_adjustments",
|
||||
name = stringResource(R.string.adjustments),
|
||||
navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "update_hearing_test",
|
||||
name = stringResource(R.string.update_hearing_test),
|
||||
navController,
|
||||
independent = true
|
||||
)
|
||||
|
||||
// not implemented yet
|
||||
|
||||
// StyledToggle(
|
||||
// title = stringResource(R.string.media_assist),
|
||||
// label = stringResource(R.string.media_assist),
|
||||
// checkedState = mediaAssistEnabled,
|
||||
// independent = true,
|
||||
// description = stringResource(R.string.media_assist_description)
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Column (
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// ) {
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_media),
|
||||
// checkedState = adjustMediaEnabled,
|
||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 12.dp)
|
||||
// )
|
||||
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_calls),
|
||||
// checkedState = adjustPhoneEnabled,
|
||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = "Enable Hearing Aid",
|
||||
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
|
||||
confirmText = "Enable",
|
||||
dismissText = "Cancel",
|
||||
onConfirm = {
|
||||
showDialog.value = false
|
||||
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
|
||||
if (!enrolled) {
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
||||
} else {
|
||||
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
||||
}
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
|
||||
hearingAidEnabled.value = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
||||
val parsed = parseTransparencySettingsResponse(data)
|
||||
val disabledSettings = parsed.copy(enabled = false)
|
||||
sendTransparencySettings(attManager, disabledSettings)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
hazeState = hazeStateS.value,
|
||||
// backdrop = backdrop
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingProtectionScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_protection),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.workspace_use),
|
||||
label = stringResource(R.string.ppe),
|
||||
description = stringResource(R.string.workspace_use_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Onboarding(navController: NavController, activityContext: Context) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
|
||||
val progressState by radareOffsetFinder.progressState.collectAsState()
|
||||
var isComplete by remember { mutableStateOf(false) }
|
||||
var hasStarted by remember { mutableStateOf(false) }
|
||||
var rootCheckPassed by remember { mutableStateOf(false) }
|
||||
var checkingRoot by remember { mutableStateOf(false) }
|
||||
var rootCheckFailed by remember { mutableStateOf(false) }
|
||||
var moduleEnabled by remember { mutableStateOf(false) }
|
||||
var bluetoothToggled by remember { mutableStateOf(false) }
|
||||
|
||||
var showSkipDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun checkRootAccess() {
|
||||
checkingRoot = true
|
||||
rootCheckFailed = false
|
||||
kotlinx.coroutines.MainScope().launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec("su -c id")
|
||||
val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this
|
||||
withContext(Dispatchers.Main) {
|
||||
rootCheckPassed = (exitValue == 0)
|
||||
rootCheckFailed = (exitValue != 0)
|
||||
checkingRoot = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Onboarding", "Root check failed", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
rootCheckPassed = false
|
||||
rootCheckFailed = true
|
||||
checkingRoot = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(hasStarted) {
|
||||
if (hasStarted && rootCheckPassed) {
|
||||
Log.d("Onboarding", "Checking if hook offset is available...")
|
||||
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
|
||||
Log.d("Onboarding", "Hook offset ready: $isHookReady")
|
||||
|
||||
if (isHookReady) {
|
||||
Log.d("Onboarding", "Hook is ready")
|
||||
isComplete = true
|
||||
} else {
|
||||
Log.d("Onboarding", "Hook not ready, starting setup process...")
|
||||
withContext(Dispatchers.IO) {
|
||||
radareOffsetFinder.setupAndFindOffset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(progressState) {
|
||||
if (progressState is RadareOffsetFinder.ProgressState.Success) {
|
||||
isComplete = true
|
||||
}
|
||||
}
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = "Setting Up",
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
showSkipDialog = true
|
||||
},
|
||||
icon = "",
|
||||
darkMode = isDarkTheme,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!rootCheckPassed && !hasStarted) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Root Access",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.root_access_required),
|
||||
style = TextStyle(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
if (rootCheckFailed) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.root_access_denied),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color(0xFFFF453A)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { checkRootAccess() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
enabled = !checkingRoot
|
||||
) {
|
||||
if (checkingRoot) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Check Root Access",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = if (hasStarted) getStatusTitle(progressState,
|
||||
moduleEnabled, bluetoothToggled) else "Setup Required",
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = if (hasStarted)
|
||||
getStatusDescription(progressState, moduleEnabled, bluetoothToggled)
|
||||
else
|
||||
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (!hasStarted) {
|
||||
Button(
|
||||
onClick = { hasStarted = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Start Setup",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (progressState) {
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
|
||||
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
label = "Download Progress"
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
color = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "${(progress * 100).toInt()}%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
if (!moduleEnabled) {
|
||||
Button(
|
||||
onClick = { moduleEnabled = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"I've Enabled/Reactivated the Module",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (!bluetoothToggled) {
|
||||
Button(
|
||||
onClick = { bluetoothToggled = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"I've Toggled Bluetooth",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
navController.navigate("settings") {
|
||||
popUpTo("onboarding") { inclusive = true }
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue to Settings",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle,
|
||||
is RadareOffsetFinder.ProgressState.Error -> {
|
||||
// No specific UI for these states
|
||||
}
|
||||
else -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
color = accentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d("Onboarding", "Trying to find offset again...")
|
||||
kotlinx.coroutines.MainScope().launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
radareOffsetFinder.setupAndFindOffset()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Try Again",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSkipDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSkipDialog = false },
|
||||
title = { Text("Skip Setup") },
|
||||
text = {
|
||||
Text(
|
||||
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSkipDialog = false
|
||||
RadareOffsetFinder.clearHookOffsets()
|
||||
sharedPreferences.edit { putBoolean("skip_setup", true) }
|
||||
navController.navigate("settings") {
|
||||
popUpTo("onboarding") { inclusive = true }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Yes, Skip Setup",
|
||||
color = accentColor,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showSkipDialog = false }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
containerColor = backgroundColor,
|
||||
textContentColor = textColor,
|
||||
titleContentColor = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusIcon(
|
||||
progressState: RadareOffsetFinder.ProgressState,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
|
||||
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (progressState) {
|
||||
is RadareOffsetFinder.ProgressState.Error -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Error",
|
||||
tint = errorColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Success",
|
||||
tint = successColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(50.dp),
|
||||
color = accentColor,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusTitle(
|
||||
state: RadareOffsetFinder.ProgressState,
|
||||
moduleEnabled: Boolean,
|
||||
bluetoothToggled: Boolean
|
||||
): String {
|
||||
return when (state) {
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
when {
|
||||
!moduleEnabled -> "Enable Xposed Module"
|
||||
!bluetoothToggled -> "Toggle Bluetooth"
|
||||
else -> "Setup Complete"
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
|
||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
|
||||
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
|
||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
|
||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
|
||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
|
||||
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusDescription(
|
||||
state: RadareOffsetFinder.ProgressState,
|
||||
moduleEnabled: Boolean,
|
||||
bluetoothToggled: Boolean
|
||||
): String {
|
||||
return when (state) {
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
when {
|
||||
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
|
||||
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
|
||||
else -> "All set! You can now use your AirPods with enhanced functionality."
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
|
||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
|
||||
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
|
||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
|
||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
|
||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
|
||||
is RadareOffsetFinder.ProgressState.Error -> state.message
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingPreview() {
|
||||
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun OpenSourceLicensesScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.open_source_licenses)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val libraries by produceLibraries {
|
||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.SelectItem
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.experimental.and
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun RightDivider() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 72.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RightDividerNoIcon() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 20.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(navController: NavController, name: String) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
|
||||
if (modesByte != null) {
|
||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
|
||||
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = name
|
||||
) { spacerHeight ->
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val actionItems = listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.digital_assistant),
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||
}
|
||||
)
|
||||
)
|
||||
StyledSelectList(items = actionItems)
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
|
||||
val allowOff = offListeningModeValue == 1.toByte()
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||
|
||||
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||
var currentByte by remember { mutableStateOf(initialByte) }
|
||||
|
||||
val listeningModeItems = mutableListOf<SelectItem>()
|
||||
if (allowOff) {
|
||||
listeningModeItems.add(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.off),
|
||||
description = "Turns off noise management",
|
||||
iconRes = R.drawable.noise_cancellation,
|
||||
selected = (currentByte and 0x01) != 0,
|
||||
onClick = {
|
||||
val bit = 0x01
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
listeningModeItems.addAll(listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.transparency),
|
||||
description = "Lets in external sounds",
|
||||
iconRes = R.drawable.transparency,
|
||||
selected = (currentByte and 0x04) != 0,
|
||||
onClick = {
|
||||
val bit = 0x04
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.adaptive),
|
||||
description = "Dynamically adjust external noise",
|
||||
iconRes = R.drawable.adaptive,
|
||||
selected = (currentByte and 0x08) != 0,
|
||||
onClick = {
|
||||
val bit = 0x08
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.noise_cancellation),
|
||||
description = "Blocks out external sounds",
|
||||
iconRes = R.drawable.noise_cancellation,
|
||||
selected = (currentByte and 0x02) != 0,
|
||||
onClick = {
|
||||
val bit = 0x02
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
)
|
||||
))
|
||||
StyledSelectList(items = listeningModeItems)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_noise_control_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||
}
|
||||
|
||||
fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.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.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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
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.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.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::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))
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.name),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
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(58.dp)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = {
|
||||
name.value = it
|
||||
sharedPreferences.edit {putString("name", it.text)}
|
||||
ServiceManager.getService()?.setName(it.text)
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(cursorColor),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
name.value = TextFieldValue("")
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenameScreenPreview() {
|
||||
RenameScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import me.kavishdevar.librepods.utils.TransparencySettings
|
||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "TransparencySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun TransparencySettingsScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val isSdpOffsetAvailable =
|
||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_transparency_mode)
|
||||
){ spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val enabled = remember { mutableStateOf(false) }
|
||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
|
||||
val transparencySettings = remember {
|
||||
mutableStateOf(
|
||||
TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val transparencyListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseTransparencySettingsResponse(value)
|
||||
enabled.value = parsed.enabled
|
||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||
balanceSliderValue.floatValue = parsed.balance
|
||||
toneSliderValue.floatValue = parsed.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue =
|
||||
parsed.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
eq.value = parsed.leftEQ.copyOf()
|
||||
Log.d(TAG, "Updated transparency settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
enabled.value,
|
||||
amplificationSliderValue.floatValue,
|
||||
balanceSliderValue.floatValue,
|
||||
toneSliderValue.floatValue,
|
||||
conversationBoostEnabled.value,
|
||||
ambientNoiseReductionSliderValue.floatValue,
|
||||
eq.value,
|
||||
initialLoadComplete.value,
|
||||
initialReadSucceeded.value
|
||||
) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Initial device read not successful yet - skipping send until read succeeds"
|
||||
)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
transparencySettings.value = TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||
sendTransparencySettings(attManager, transparencySettings.value)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
|
||||
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
||||
|
||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
||||
try {
|
||||
if (aacpManager != null) {
|
||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||
val aacpEQ = aacpManager.eqData
|
||||
if (aacpEQ.isNotEmpty()) {
|
||||
eq.value = aacpEQ.copyOf()
|
||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||
} else {
|
||||
Log.d(TAG, "AACPManager EQ data empty")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No AACPManager available")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||
}
|
||||
|
||||
var parsedSettings: TransparencySettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||
enabled.value = parsedSettings.enabled
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue =
|
||||
parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
eq.value = parsedSettings.leftEQ.copyOf()
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only show transparency mode section if SDP offset is available
|
||||
if (isSdpOffsetAvailable.value) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.transparency_mode),
|
||||
checkedState = enabled,
|
||||
independent = true,
|
||||
description = stringResource(R.string.customize_transparency_mode_description)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = amplificationSliderValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = balanceSliderValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = toneSliderValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checkedState = conversationBoostEnabled,
|
||||
independent = true,
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
}
|
||||
|
||||
// Only show transparency mode EQ section if SDP offset is available
|
||||
if (isSdpOffsetAvailable.value) {
|
||||
Text(
|
||||
text = stringResource(R.string.equalizer),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
for (i in 0 until 8) {
|
||||
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.2f", eqValue.floatValue),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = eqValue.floatValue,
|
||||
onValueChange = { newVal ->
|
||||
eqValue.floatValue = newVal
|
||||
val newEQ = eq.value.copyOf()
|
||||
newEQ[i] = eqValue.floatValue
|
||||
eq.value = newEQ
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(eqValue.floatValue / 100f)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
activeTrackColor,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.band_label, i + 1),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,918 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.utils.LogCollector
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CustomIconButton(
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun TroubleshootingScreen(navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val logCollector = remember { LogCollector(context) }
|
||||
val savedLogs = remember { mutableStateListOf<File>() }
|
||||
|
||||
var isCollectingLogs by remember { mutableStateOf(false) }
|
||||
var showTroubleshootingSteps by remember { mutableStateOf(false) }
|
||||
var currentStep by remember { mutableIntStateOf(0) }
|
||||
var logContent by remember { mutableStateOf("") }
|
||||
var selectedLogFile by remember { mutableStateOf<File?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteAllDialog by remember { mutableStateOf(false) }
|
||||
var isLoadingLogContent by remember { mutableStateOf(false) }
|
||||
var logContentLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isCollectingLogs) {
|
||||
while (isCollectingLogs) {
|
||||
delay(250)
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
|
||||
|
||||
var instructionText by remember { mutableStateOf("") }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val logsDir = File(context.filesDir, "logs")
|
||||
if (logsDir.exists()) {
|
||||
savedLogs.clear()
|
||||
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
|
||||
?.sortedByDescending { it.lastModified() } ?: emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(logContent.toByteArray())
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to save log: ${e.localizedMessage}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentStep) {
|
||||
instructionText = when (currentStep) {
|
||||
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
|
||||
1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely."
|
||||
2 -> "Preparing to collect logs... Please wait."
|
||||
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
|
||||
4 -> "Log collection complete! You can now save or share the logs."
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun openLogBottomSheet(file: File) {
|
||||
selectedLogFile = file
|
||||
logContent = ""
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = false
|
||||
showBottomSheet = true
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.troubleshooting)
|
||||
){ spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(state = hazeState)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.saved_logs),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
if (savedLogs.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_logs_found),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Total Logs: ${savedLogs.size}",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (savedLogs.size > 1) {
|
||||
TextButton(
|
||||
onClick = { showDeleteAllDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete All")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savedLogs.forEach { logFile ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable {
|
||||
openLogBottomSheet(logFile)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = logFile.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(logFile.lastModified())),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
onClick = {
|
||||
selectedLogFile = logFile
|
||||
showDeleteDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(300))
|
||||
) {
|
||||
Button(
|
||||
onClick = { showTroubleshootingSteps = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
enabled = !isCollectingLogs
|
||||
) {
|
||||
Text(stringResource(R.string.collect_logs))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)) +
|
||||
slideInVertically(animationSpec = tween(300)) { it / 2 },
|
||||
exit = fadeOut(animationSpec = tween(300)) +
|
||||
slideOutVertically(animationSpec = tween(300)) { it / 2 }
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting_steps),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val textAlpha = animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "textAlpha"
|
||||
)
|
||||
|
||||
Text(
|
||||
text = instructionText,
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = textAlpha.value),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (currentStep) {
|
||||
0 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.openXposedSettings(context)
|
||||
delay(2000)
|
||||
currentStep = 1
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Open Xposed Settings")
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 2
|
||||
isCollectingLogs = true
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
logCollector.clearLogs()
|
||||
|
||||
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
|
||||
|
||||
logCollector.killBluetoothService()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(500)
|
||||
currentStep = 3
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.US
|
||||
).format(Date())
|
||||
|
||||
logContent =
|
||||
logCollector.startLogCollection(
|
||||
listener = { /* Removed live log display */ },
|
||||
connectionDetectedCallback = {
|
||||
launch {
|
||||
delay(5000)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isCollectingLogs) {
|
||||
logCollector.stopLogCollection()
|
||||
currentStep = 4
|
||||
isCollectingLogs =
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val logFile =
|
||||
logCollector.saveLogToInternalStorage(
|
||||
"airpods_log_$timestamp.txt",
|
||||
logContent
|
||||
)
|
||||
logFile?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
savedLogs.add(0, it)
|
||||
selectedLogFile = it
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log saved: ${it.name}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Error collecting logs: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
isCollectingLogs = false
|
||||
currentStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
|
||||
2, 3 -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
|
||||
fontSize = 14.sp,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (currentStep == 3) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.addLogMarker(
|
||||
LogCollector.LogMarkerType.CUSTOM,
|
||||
"Manual stop requested by user"
|
||||
)
|
||||
delay(1000)
|
||||
logCollector.stopLogCollection()
|
||||
delay(500)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
currentStep = 4
|
||||
isCollectingLogs = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log collection stopped",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text("Stop Collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4 -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
fileUri
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(
|
||||
file.absolutePath
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 0
|
||||
showTroubleshootingSteps = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && selectedLogFile != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Log File") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete this log file? This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
if (file.delete()) {
|
||||
savedLogs.remove(file)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log file deleted",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log file",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteAllDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteAllDialog = false },
|
||||
title = { Text("Delete All Logs") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
var deletedCount = 0
|
||||
savedLogs.forEach { file ->
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (deletedCount > 0) {
|
||||
savedLogs.clear()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Deleted $deletedCount log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteAllDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete All", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteAllDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
LaunchedEffect(selectedLogFile) {
|
||||
if (!logContentLoaded) {
|
||||
delay(300)
|
||||
withContext(Dispatchers.IO) {
|
||||
isLoadingLogContent = true
|
||||
logContent = try {
|
||||
selectedLogFile?.readText() ?: ""
|
||||
} catch (e: Exception) {
|
||||
"Error loading log content: ${e.message}"
|
||||
}
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = selectedLogFile?.name ?: "Log Content",
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(selectedLogFile?.lastModified() ?: 0)),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingLogContent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = accentColor)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(
|
||||
color = Color.Black,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
val horizontalScrollState = rememberScrollState()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.horizontalScroll(horizontalScrollState)
|
||||
.verticalScroll(verticalScrollState)
|
||||
) {
|
||||
Text(
|
||||
text = logContent,
|
||||
fontSize = 14.sp,
|
||||
color = Color.LightGray,
|
||||
lineHeight = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(file.absolutePath)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
logCollector.stopLogCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val attManager = ServiceManager.getService()?.attManager
|
||||
if (attManager == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_test)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_test_value_instruction),
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = 0.5f,
|
||||
rightAmplification = 0.5f,
|
||||
leftTone = 0.5f,
|
||||
rightTone = 0.5f,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = 0.0f,
|
||||
rightAmbientNoiseReduction = 0.0f,
|
||||
netAmplification = 0.5f,
|
||||
balance = 0.5f,
|
||||
ownVoiceAmplification = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hearingAidEnabled = remember {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
val hearingAidListener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hearingAidATTListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = 0.5f,
|
||||
rightAmplification = 0.5f,
|
||||
leftTone = 0.5f,
|
||||
rightTone = 0.5f,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = 0.0f,
|
||||
rightAmbientNoiseReduction = 0.0f,
|
||||
netAmplification = 0.5f,
|
||||
balance = 0.5f,
|
||||
ownVoiceAmplification = 0.5f
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
|
||||
try {
|
||||
if (aacpManager != null) {
|
||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||
val aacpEQ = aacpManager.eqData
|
||||
if (aacpEQ.isNotEmpty()) {
|
||||
leftEQ.value = aacpEQ.copyOf()
|
||||
rightEQ.value = aacpEQ.copyOf()
|
||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||
} else {
|
||||
Log.d(TAG, "AACPManager EQ data empty")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No AACPManager available")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||
}
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(60.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
}
|
||||
|
||||
frequencies.forEachIndexed { index, freq ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = freq,
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
textAlign = TextAlign.End,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = leftEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = leftEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
leftEQ.value = newArray
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rightEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = rightEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
rightEQ.value = newArray
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun VersionScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_adaptive_audio)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.version),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 1",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version1 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 2",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version2 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 3",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version3 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class AirPodsQSService : TileService() {
|
||||
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private var currentAncMode: Int = NoiseControlMode.OFF.ordinal + 1
|
||||
private var isAirPodsConnected: Boolean = false
|
||||
|
||||
private val ancStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
||||
val newMode = intent.getIntExtra("data", NoiseControlMode.OFF.ordinal + 1)
|
||||
Log.d("AirPodsQSService", "Received ANC update: $newMode")
|
||||
currentAncMode = newMode
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val availabilityReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||
Log.d("AirPodsQSService", "Received AIRPODS_CONNECTED")
|
||||
isAirPodsConnected = true
|
||||
currentAncMode =
|
||||
ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
||||
updateTile()
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||
Log.d("AirPodsQSService", "Received AIRPODS_DISCONNECTED")
|
||||
isAirPodsConnected = false
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "off_listening_mode") {
|
||||
Log.d("AirPodsQSService", "Preference changed: $key")
|
||||
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1
|
||||
}
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
Log.d("AirPodsQSService", "onStartListening")
|
||||
|
||||
val service = ServiceManager.getService()
|
||||
isAirPodsConnected = service?.isConnectedLocally == true
|
||||
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
||||
|
||||
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1
|
||||
}
|
||||
|
||||
val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA)
|
||||
val availabilityIntentFilter = IntentFilter().apply {
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
}
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED)
|
||||
registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(ancStatusReceiver, ancIntentFilter)
|
||||
registerReceiver(availabilityReceiver, availabilityIntentFilter)
|
||||
}
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
Log.d("AirPodsQSService", "Receivers registered")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsQSService", "Error registering receivers: $e")
|
||||
}
|
||||
|
||||
updateTile()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
super.onStopListening()
|
||||
Log.d("AirPodsQSService", "onStopListening")
|
||||
try {
|
||||
unregisterReceiver(ancStatusReceiver)
|
||||
unregisterReceiver(availabilityReceiver)
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
Log.d("AirPodsQSService", "Receivers unregistered")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("AirPodsQSService", "Receiver not registered or already unregistered: $e")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsQSService", "Error unregistering receivers: $e")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
Log.d("AirPodsQSService", "onClick - Current state: $isAirPodsConnected, Current mode: $currentAncMode")
|
||||
if (!isAirPodsConnected) {
|
||||
Log.d("AirPodsQSService", "Tile clicked but AirPods not connected.")
|
||||
return
|
||||
}
|
||||
|
||||
val clickBehavior = sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog"
|
||||
|
||||
if (clickBehavior == "dialog") {
|
||||
launchDialogActivity()
|
||||
} else {
|
||||
cycleAncMode()
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchDialogActivity() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsQSService", "Error launching QuickSettingsDialogActivity: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cycleAncMode() {
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) {
|
||||
Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.")
|
||||
return
|
||||
}
|
||||
val nextMode = getNextAncMode()
|
||||
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
nextMode
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val tile = qsTile ?: return
|
||||
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
|
||||
|
||||
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
|
||||
|
||||
if (isAirPodsConnected) {
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = getModeLabel(currentAncMode)
|
||||
tile.subtitle = deviceName
|
||||
tile.icon = Icon.createWithResource(this, getModeIcon(currentAncMode))
|
||||
} else {
|
||||
tile.state = Tile.STATE_UNAVAILABLE
|
||||
tile.label = "AirPods"
|
||||
tile.subtitle = "Disconnected"
|
||||
tile.icon = Icon.createWithResource(this, R.drawable.airpods)
|
||||
}
|
||||
|
||||
try {
|
||||
tile.updateTile()
|
||||
Log.d("AirPodsQSService", "Tile updated successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsQSService", "Error updating tile: $e")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOffModeEnabled(): Boolean {
|
||||
return sharedPreferences.getBoolean("off_listening_mode", true)
|
||||
}
|
||||
|
||||
private fun getAvailableModes(): List<Int> {
|
||||
val modes = mutableListOf(
|
||||
NoiseControlMode.TRANSPARENCY.ordinal + 1,
|
||||
NoiseControlMode.ADAPTIVE.ordinal + 1,
|
||||
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1
|
||||
)
|
||||
if (isOffModeEnabled()) {
|
||||
modes.add(0, NoiseControlMode.OFF.ordinal + 1)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
private fun getNextAncMode(): Int {
|
||||
val availableModes = getAvailableModes()
|
||||
val currentIndex = availableModes.indexOf(currentAncMode)
|
||||
val nextIndex = (currentIndex + 1) % availableModes.size
|
||||
return availableModes[nextIndex]
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: Int): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF.ordinal + 1 -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY.ordinal + 1 -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE.ordinal + 1 -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> "Noise Cancellation"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeIcon(mode: Int): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF.ordinal + 1 -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.noise_cancellation
|
||||
else -> R.drawable.airpods
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTileAdded() {
|
||||
super.onTileAdded()
|
||||
Log.d("AirPodsQSService", "Tile added")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.services
|
||||
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG="AppListenerService"
|
||||
|
||||
val cameraPackages = mutableSetOf(
|
||||
"com.google.android.GoogleCamera",
|
||||
"com.sec.android.app.camera",
|
||||
"com.android.camera",
|
||||
"com.oppo.camera",
|
||||
"com.motorola.camera2",
|
||||
"org.codeaurora.snapcam"
|
||||
)
|
||||
|
||||
var cameraOpen = false
|
||||
private var currentCustomPackage: String? = null
|
||||
|
||||
class AppListenerService : AccessibilityService() {
|
||||
private lateinit var prefs: android.content.SharedPreferences
|
||||
private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
|
||||
if (key == "custom_camera_package") {
|
||||
val newPackage = sharedPreferences.getString(key, null)
|
||||
currentCustomPackage?.let { cameraPackages.remove(it) }
|
||||
if (newPackage != null && newPackage.isNotBlank()) {
|
||||
cameraPackages.add(newPackage)
|
||||
}
|
||||
currentCustomPackage = newPackage
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val customPackage = prefs.getString("custom_camera_package", null)
|
||||
if (customPackage != null && customPackage.isNotBlank()) {
|
||||
cameraPackages.add(customPackage)
|
||||
currentCustomPackage = customPackage
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(ev: AccessibilityEvent?) {
|
||||
try {
|
||||
if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
|
||||
val pkg = ev.packageName?.toString() ?: return
|
||||
if (pkg == "com.android.systemui") return // after camera opens, systemui is opened, probably for the privacy indicators
|
||||
Log.d(TAG, "Package: $pkg, cameraOpen: $cameraOpen")
|
||||
if (pkg in cameraPackages) {
|
||||
Log.d(TAG, "Camera app opened: $pkg")
|
||||
if (!cameraOpen) cameraOpen = true
|
||||
ServiceManager.getService()?.cameraOpened()
|
||||
} else {
|
||||
if (cameraOpen) {
|
||||
cameraOpen = false
|
||||
ServiceManager.getService()?.cameraClosed()
|
||||
} else {
|
||||
Log.d(TAG, "ignoring")
|
||||
}
|
||||
}
|
||||
// Log.d(TAG, "Opened: $pkg")
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -1,4 +1,22 @@
|
||||
package me.kavishdevar.aln.ui.theme
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -20,22 +38,11 @@ private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ALNTheme(
|
||||
fun LibrePodsTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
@@ -1,4 +1,22 @@
|
||||
package me.kavishdevar.aln.ui.theme
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
|
||||
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
|
||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
enum class ATTHandles(val value: Int) {
|
||||
TRANSPARENCY(0x18),
|
||||
LOUD_SOUND_REDUCTION(0x1B),
|
||||
HEARING_AID(0x2A),
|
||||
}
|
||||
|
||||
enum class ATTCCCDHandles(val value: Int) {
|
||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||
}
|
||||
|
||||
class ATTManager(private val device: BluetoothDevice) {
|
||||
companion object {
|
||||
private const val TAG = "ATTManager"
|
||||
|
||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
||||
}
|
||||
|
||||
var socket: BluetoothSocket? = null
|
||||
private var input: InputStream? = null
|
||||
private var output: OutputStream? = null
|
||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||
private var notificationJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
// queue for non-notification PDUs (responses to requests)
|
||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connect() {
|
||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
socket = createBluetoothSocket(device, uuid)
|
||||
socket!!.connect()
|
||||
input = socket!!.inputStream
|
||||
output = socket!!.outputStream
|
||||
Log.d(TAG, "Connected to ATT")
|
||||
|
||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (socket?.isConnected == true) {
|
||||
try {
|
||||
val pdu = readPDU()
|
||||
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
|
||||
// notification -> dispatch to listeners
|
||||
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
|
||||
val value = pdu.copyOfRange(3, pdu.size)
|
||||
listeners[handle]?.forEach { listener ->
|
||||
try {
|
||||
listener(value)
|
||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not a notification -> treat as a response for pending request(s)
|
||||
responses.put(pdu)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
||||
if (socket?.isConnected != true) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
try {
|
||||
notificationJob?.cancel()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners[handle.value]?.remove(listener)
|
||||
}
|
||||
|
||||
fun enableNotifications(handle: ATTHandles) {
|
||||
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
|
||||
}
|
||||
|
||||
fun read(handle: ATTHandles): ByteArray {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
|
||||
writeRaw(pdu)
|
||||
// wait for response placed into responses queue by the reader coroutine
|
||||
return readResponse()
|
||||
}
|
||||
|
||||
fun write(handle: ATTHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun write(handle: ATTCCCDHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeRaw(pdu: ByteArray) {
|
||||
output?.write(pdu)
|
||||
output?.flush()
|
||||
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
|
||||
// rename / specialize: read raw PDU directly from input stream (blocking)
|
||||
private fun readPDU(): ByteArray {
|
||||
val inp = input ?: throw IllegalStateException("Not connected")
|
||||
val buffer = ByteArray(512)
|
||||
val len = inp.read(buffer)
|
||||
if (len == -1) {
|
||||
disconnect()
|
||||
throw IllegalStateException("End of stream reached")
|
||||
}
|
||||
val data = buffer.copyOfRange(0, len)
|
||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return data
|
||||
}
|
||||
|
||||
// wait for a response PDU produced by the background reader
|
||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
||||
try {
|
||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return resp.copyOfRange(1, resp.size)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw IllegalStateException("Interrupted while waiting for ATT response", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(device, type, true, true, 31, uuid),
|
||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||
arrayOf(type, true, true, device, 31, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d("ATTManager", "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
|
||||
} catch (e: Exception) {
|
||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e("ATTManager", errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTManager
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
open class AirPodsBase(
|
||||
val modelNumber: List<String>,
|
||||
val name: String,
|
||||
val displayName: String = "AirPods",
|
||||
val manufacturer: String = "Apple Inc.",
|
||||
val budCaseRes: Int,
|
||||
val budsRes: Int,
|
||||
val leftBudsRes: Int,
|
||||
val rightBudsRes: Int,
|
||||
val caseRes: Int,
|
||||
val capabilities: Set<Capability>
|
||||
)
|
||||
enum class Capability {
|
||||
LISTENING_MODE,
|
||||
CONVERSATION_AWARENESS,
|
||||
STEM_CONFIG,
|
||||
HEAD_GESTURES,
|
||||
LOUD_SOUND_REDUCTION,
|
||||
PPE,
|
||||
SLEEP_DETECTION,
|
||||
HEARING_AID,
|
||||
ADAPTIVE_AUDIO,
|
||||
ADAPTIVE_VOLUME,
|
||||
SWIPE_FOR_VOLUME,
|
||||
HRM
|
||||
}
|
||||
|
||||
class AirPods: AirPodsBase(
|
||||
modelNumber = listOf("A1523", "A1722"),
|
||||
name = "AirPods 1",
|
||||
budCaseRes = R.drawable.airpods_1,
|
||||
budsRes = R.drawable.airpods_1_buds,
|
||||
leftBudsRes = R.drawable.airpods_1_left,
|
||||
rightBudsRes = R.drawable.airpods_1_right,
|
||||
caseRes = R.drawable.airpods_1_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods2: AirPodsBase(
|
||||
modelNumber = listOf("A2032", "A2031"),
|
||||
name = "AirPods 2",
|
||||
budCaseRes = R.drawable.airpods_2,
|
||||
budsRes = R.drawable.airpods_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_2_left,
|
||||
rightBudsRes = R.drawable.airpods_2_right,
|
||||
caseRes = R.drawable.airpods_2_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods3: AirPodsBase(
|
||||
modelNumber = listOf("A2565", "A2564"),
|
||||
name = "AirPods 3",
|
||||
budCaseRes = R.drawable.airpods_3,
|
||||
budsRes = R.drawable.airpods_3_buds,
|
||||
leftBudsRes = R.drawable.airpods_3_left,
|
||||
rightBudsRes = R.drawable.airpods_3_right,
|
||||
caseRes = R.drawable.airpods_3_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4: AirPodsBase(
|
||||
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||
name = "AirPods 4",
|
||||
budCaseRes = R.drawable.airpods_4,
|
||||
budsRes = R.drawable.airpods_4_buds,
|
||||
leftBudsRes = R.drawable.airpods_4_left,
|
||||
rightBudsRes = R.drawable.airpods_4_right,
|
||||
caseRes = R.drawable.airpods_4_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4ANC: AirPodsBase(
|
||||
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||
name = "AirPods 4 (ANC)",
|
||||
budCaseRes = R.drawable.airpods_4,
|
||||
budsRes = R.drawable.airpods_4_buds,
|
||||
leftBudsRes = R.drawable.airpods_4_left,
|
||||
rightBudsRes = R.drawable.airpods_4_right,
|
||||
caseRes = R.drawable.airpods_4_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro1: AirPodsBase(
|
||||
modelNumber = listOf("A2084", "A2083"),
|
||||
name = "AirPods Pro 1",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_1,
|
||||
budsRes = R.drawable.airpods_pro_1_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_1_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_1_right,
|
||||
caseRes = R.drawable.airpods_pro_1_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2Lightning: AirPodsBase(
|
||||
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2USBC: AirPodsBase(
|
||||
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro3: AirPodsBase(
|
||||
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||
name = "AirPods Pro 3",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_3,
|
||||
budsRes = R.drawable.airpods_pro_3_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_3_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_3_right,
|
||||
caseRes = R.drawable.airpods_pro_3_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.PPE,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HRM
|
||||
)
|
||||
)
|
||||
|
||||
data class AirPodsInstance(
|
||||
val name: String,
|
||||
val model: AirPodsBase,
|
||||
val actualModelNumber: String,
|
||||
val serialNumber: String?,
|
||||
val leftSerialNumber: String?,
|
||||
val rightSerialNumber: String?,
|
||||
val version1: String?,
|
||||
val version2: String?,
|
||||
val version3: String?,
|
||||
val aacpManager: AACPManager,
|
||||
val attManager: ATTManager?
|
||||
)
|
||||
|
||||
object AirPodsModels {
|
||||
val models: List<AirPodsBase> = listOf(
|
||||
AirPods(),
|
||||
AirPods2(),
|
||||
AirPods3(),
|
||||
AirPods4(),
|
||||
AirPods4ANC(),
|
||||
AirPodsPro1(),
|
||||
AirPodsPro2Lightning(),
|
||||
AirPodsPro2USBC(),
|
||||
AirPodsPro3()
|
||||
)
|
||||
|
||||
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
|
||||
return models.find { modelNumber in it.modelNumber }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class BLEManager(private val context: Context) {
|
||||
|
||||
data class AirPodsStatus(
|
||||
val address: String,
|
||||
val lastSeen: Long = System.currentTimeMillis(),
|
||||
val paired: Boolean = false,
|
||||
val model: String = "Unknown",
|
||||
val leftBattery: Int? = null,
|
||||
val rightBattery: Int? = null,
|
||||
val caseBattery: Int? = null,
|
||||
val isLeftInEar: Boolean = false,
|
||||
val isRightInEar: Boolean = false,
|
||||
val isLeftCharging: Boolean = false,
|
||||
val isRightCharging: Boolean = false,
|
||||
val isCaseCharging: Boolean = false,
|
||||
val lidOpen: Boolean = false,
|
||||
val color: String = "Unknown",
|
||||
val connectionState: String = "Unknown"
|
||||
)
|
||||
|
||||
fun getMostRecentStatus(): AirPodsStatus? {
|
||||
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
|
||||
}
|
||||
|
||||
interface AirPodsStatusListener {
|
||||
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
|
||||
fun onBroadcastFromNewAddress(device: AirPodsStatus)
|
||||
fun onLidStateChanged(lidOpen: Boolean)
|
||||
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
|
||||
fun onBatteryChanged(device: AirPodsStatus)
|
||||
fun onDeviceDisappeared()
|
||||
}
|
||||
|
||||
private var mBluetoothLeScanner: BluetoothLeScanner? = null
|
||||
private var mScanCallback: ScanCallback? = null
|
||||
private var airPodsStatusListener: AirPodsStatusListener? = null
|
||||
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
|
||||
private val verifiedAddresses = mutableSetOf<String>()
|
||||
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
private var currentGlobalLidState: Boolean? = null
|
||||
private var lastBroadcastTime: Long = 0
|
||||
private val processedAddresses = mutableSetOf<String>()
|
||||
|
||||
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
|
||||
private val modelNames = mapOf(
|
||||
0x0E20 to "AirPods Pro",
|
||||
0x1420 to "AirPods Pro 2",
|
||||
0x2420 to "AirPods Pro 2 (USB-C)",
|
||||
0x0220 to "AirPods 1",
|
||||
0x0F20 to "AirPods 2",
|
||||
0x1320 to "AirPods 3",
|
||||
0x1920 to "AirPods 4",
|
||||
0x1B20 to "AirPods 4 (ANC)",
|
||||
0x0A20 to "AirPods Max",
|
||||
0x1F20 to "AirPods Max (USB-C)"
|
||||
)
|
||||
|
||||
val colorNames = mapOf(
|
||||
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
|
||||
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
|
||||
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
|
||||
0x0B to "Light Blue", 0x0C to "Yellow"
|
||||
)
|
||||
|
||||
val connStates = mapOf(
|
||||
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
|
||||
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
|
||||
)
|
||||
|
||||
private val cleanupHandler = Handler(Looper.getMainLooper())
|
||||
private val cleanupRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
cleanupStaleDevices()
|
||||
checkLidStateTimeout()
|
||||
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
|
||||
airPodsStatusListener = listener
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScanning() {
|
||||
try {
|
||||
Log.d(TAG, "Starting BLE scanner")
|
||||
|
||||
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val btAdapter = btManager.adapter
|
||||
|
||||
if (btAdapter == null) {
|
||||
Log.d(TAG, "No Bluetooth adapter available")
|
||||
return
|
||||
}
|
||||
|
||||
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||
mScanCallback = null
|
||||
}
|
||||
|
||||
if (!btAdapter.isEnabled) {
|
||||
Log.d(TAG, "Bluetooth is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
|
||||
|
||||
val scanSettings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
.setReportDelay(500L)
|
||||
.build()
|
||||
|
||||
val manufacturerData = ByteArray(27)
|
||||
val manufacturerDataMask = ByteArray(27)
|
||||
|
||||
manufacturerData[0] = 7
|
||||
manufacturerData[1] = 25
|
||||
|
||||
manufacturerDataMask[0] = -1
|
||||
manufacturerDataMask[1] = -1
|
||||
|
||||
val scanFilter = ScanFilter.Builder()
|
||||
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
|
||||
.build()
|
||||
|
||||
mScanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
processScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: List<ScanResult>) {
|
||||
processedAddresses.clear()
|
||||
for (result in results) {
|
||||
processScanResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "BLE scan failed with error code: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
|
||||
Log.d(TAG, "BLE scanner started successfully")
|
||||
|
||||
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error starting BLE scanner", t)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScanning() {
|
||||
try {
|
||||
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||
Log.d(TAG, "Stopping BLE scanner")
|
||||
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||
mScanCallback = null
|
||||
}
|
||||
|
||||
cleanupHandler.removeCallbacks(cleanupRunnable)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error stopping BLE scanner", t)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
||||
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||
return if (keyBase64 != null) {
|
||||
try {
|
||||
Base64.decode(keyBase64)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode encryption key", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("GetInstance")
|
||||
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
|
||||
return try {
|
||||
if (data.size < 16) {
|
||||
return null
|
||||
}
|
||||
|
||||
val block = data.copyOfRange(data.size - 16, data.size)
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey)
|
||||
cipher.doFinal(block)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decrypting data", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
|
||||
val charging = (byteVal and 0x80) != 0
|
||||
val level = byteVal and 0x7F
|
||||
return Pair(charging, level)
|
||||
}
|
||||
|
||||
private fun processScanResult(result: ScanResult) {
|
||||
try {
|
||||
val scanRecord = result.scanRecord ?: return
|
||||
val address = result.device.address
|
||||
|
||||
if (processedAddresses.contains(address)) {
|
||||
return
|
||||
}
|
||||
|
||||
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||
if (manufacturerData.size <= 20) return
|
||||
|
||||
if (!verifiedAddresses.contains(address)) {
|
||||
val irk = getIrkFromPreferences()
|
||||
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||
return
|
||||
}
|
||||
verifiedAddresses.add(address)
|
||||
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||
}
|
||||
|
||||
processedAddresses.add(address)
|
||||
lastBroadcastTime = System.currentTimeMillis()
|
||||
|
||||
val encryptionKey = getEncryptionKeyFromPreferences()
|
||||
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||
} else {
|
||||
parseProximityMessage(address, manufacturerData)
|
||||
}
|
||||
|
||||
val previousStatus = deviceStatusMap[address]
|
||||
deviceStatusMap[address] = parsedStatus
|
||||
|
||||
airPodsStatusListener?.let { listener ->
|
||||
if (previousStatus == null) {
|
||||
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||
Log.d(TAG, "New AirPods device detected: $address")
|
||||
|
||||
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||
}
|
||||
} else {
|
||||
if (parsedStatus != previousStatus) {
|
||||
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||
}
|
||||
|
||||
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||
val previousGlobalState = currentGlobalLidState
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
|
||||
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||
listener.onEarStateChanged(
|
||||
parsedStatus,
|
||||
parsedStatus.isLeftInEar,
|
||||
parsedStatus.isRightInEar
|
||||
)
|
||||
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||
}
|
||||
|
||||
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||
listener.onBatteryChanged(parsedStatus)
|
||||
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error processing scan result", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||
val paired = data[2].toInt() == 1
|
||||
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||
|
||||
val status = data[5].toInt() and 0xFF
|
||||
// val flagsCase = data[7].toInt() and 0xFF
|
||||
val lid = data[8].toInt() and 0xFF
|
||||
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||
|
||||
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||
val xorFactor = primaryLeft xor thisInCase
|
||||
|
||||
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||
|
||||
val isFlipped = !primaryLeft
|
||||
|
||||
val leftByteIndex = if (isFlipped) 2 else 1
|
||||
val rightByteIndex = if (isFlipped) 1 else 2
|
||||
|
||||
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
|
||||
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
|
||||
|
||||
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
|
||||
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
|
||||
|
||||
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
|
||||
lastValidCaseBatteryMap[address]
|
||||
} else {
|
||||
lastValidCaseBatteryMap[address] = rawCaseBattery
|
||||
rawCaseBattery
|
||||
}
|
||||
|
||||
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||
|
||||
return AirPodsStatus(
|
||||
address = address,
|
||||
lastSeen = System.currentTimeMillis(),
|
||||
paired = paired,
|
||||
model = model,
|
||||
leftBattery = leftBattery,
|
||||
rightBattery = rightBattery,
|
||||
caseBattery = caseBattery,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isLeftCharging = isLeftCharging,
|
||||
isRightCharging = isRightCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
lidOpen = lidOpen,
|
||||
color = color,
|
||||
connectionState = conn
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanupStaleDevices() {
|
||||
val now = System.currentTimeMillis()
|
||||
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||
val hadDevices = deviceStatusMap.isNotEmpty()
|
||||
|
||||
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
||||
|
||||
for (device in staleDevices) {
|
||||
deviceStatusMap.remove(device.key)
|
||||
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||
}
|
||||
|
||||
if (hadDevices && deviceStatusMap.isEmpty()) {
|
||||
airPodsStatusListener?.onDeviceDisappeared()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkLidStateTimeout() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
|
||||
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
|
||||
currentGlobalLidState = false
|
||||
airPodsStatusListener?.onLidStateChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getIrkFromPreferences(): ByteArray? {
|
||||
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||
return if (irkBase64 != null) {
|
||||
try {
|
||||
Base64.decode(irkBase64)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode IRK", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
|
||||
val paired = data[2].toInt() == 1
|
||||
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||
|
||||
val status = data[5].toInt() and 0xFF
|
||||
val podsBattery = data[6].toInt() and 0xFF
|
||||
val flagsCase = data[7].toInt() and 0xFF
|
||||
val lid = data[8].toInt() and 0xFF
|
||||
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||
|
||||
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||
val xorFactor = primaryLeft xor thisInCase
|
||||
|
||||
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||
|
||||
val isFlipped = !primaryLeft
|
||||
|
||||
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
|
||||
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
|
||||
|
||||
val caseBattery = flagsCase and 0x0F
|
||||
val flags = (flagsCase shr 4) and 0x0F
|
||||
|
||||
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
|
||||
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
|
||||
val isCaseCharging = (flags and 0x04) != 0
|
||||
|
||||
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||
|
||||
fun decodeBattery(n: Int): Int? = when (n) {
|
||||
in 0x0..0x9 -> n * 10
|
||||
in 0xA..0xE -> 100
|
||||
0xF -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
return AirPodsStatus(
|
||||
address = address,
|
||||
lastSeen = System.currentTimeMillis(),
|
||||
paired = paired,
|
||||
model = model,
|
||||
leftBattery = decodeBattery(leftBatteryNibble),
|
||||
rightBattery = decodeBattery(rightBatteryNibble),
|
||||
caseBattery = decodeBattery(caseBattery),
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isLeftCharging = isLeftCharging,
|
||||
isRightCharging = isRightCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
lidOpen = lidOpen,
|
||||
color = color,
|
||||
connectionState = conn
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPodsBLE"
|
||||
private const val CLEANUP_INTERVAL_MS = 10000L
|
||||
private const val STALE_DEVICE_TIMEOUT_MS = 15000L
|
||||
private const val LID_CLOSE_TIMEOUT_MS = 2500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
|
||||
object BluetoothConnectionManager {
|
||||
private const val TAG = "BluetoothConnectionManager"
|
||||
|
||||
private var currentSocket: BluetoothSocket? = null
|
||||
private var currentDevice: BluetoothDevice? = null
|
||||
|
||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||
currentSocket = socket
|
||||
currentDevice = device
|
||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||
}
|
||||
|
||||
fun getCurrentSocket(): BluetoothSocket? {
|
||||
return currentSocket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Utilities for Bluetooth cryptography operations, particularly for
|
||||
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||
*/
|
||||
object BluetoothCryptography {
|
||||
|
||||
/**
|
||||
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||
*
|
||||
* @param addr The Bluetooth address to verify
|
||||
* @param irk The Identity Resolving Key to use for verification
|
||||
* @return true if the address is verified as an RPA matching the IRK
|
||||
*/
|
||||
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||
val prand = rpa.copyOfRange(3, 6)
|
||||
val hash = rpa.copyOfRange(0, 3)
|
||||
val computedHash = ah(irk, prand)
|
||||
return hash.contentEquals(computedHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param key The key for encryption
|
||||
* @param data The data to encrypt
|
||||
* @return The encrypted data
|
||||
*/
|
||||
@SuppressLint("GetInstance")
|
||||
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val swappedKey = key.reversedArray()
|
||||
val swappedData = data.reversedArray()
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
return cipher.doFinal(swappedData).reversedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the ah function as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param k The IRK key
|
||||
* @param r The random part of the address
|
||||
* @return The hash part of the address
|
||||
*/
|
||||
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||
val rPadded = ByteArray(16)
|
||||
r.copyInto(rPadded, 0, 0, 3)
|
||||
val encrypted = e(k, rPadded)
|
||||
return encrypted.copyOfRange(0, 3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.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 androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||
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)}
|
||||
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) {
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
serverSocket?.close()
|
||||
break
|
||||
}
|
||||
if (clientSocket != null) {
|
||||
try {
|
||||
clientSocket!!.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val socket = serverSocket!!.accept()
|
||||
handleClientConnection(socket)
|
||||
} catch (e: IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "unused")
|
||||
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()
|
||||
try {
|
||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
|
||||
}
|
||||
Log.d("CrossDevice", "BLE Advertising started")
|
||||
}
|
||||
}
|
||||
|
||||
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)}
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
|
||||
} else {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
||||
// Reset state variables
|
||||
isAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReceivedPacket(packet: ByteArray) {
|
||||
if (clientSocket == null || clientSocket!!.outputStream != 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)}
|
||||
}
|
||||
|
||||
@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()?.disconnectForCD()
|
||||
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)}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
|
||||
isAvailable = false
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
||||
} 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) }
|
||||
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()?.updateBattery()
|
||||
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.librepods.cross_device_island")
|
||||
)
|
||||
}
|
||||
earDetectionStatus = newEarDetectionStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendRemotePacket(byteArray: ByteArray) {
|
||||
if (clientSocket == null || clientSocket!!.outputStream == 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.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun notifyAirPodsDisconnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerId
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.util.fastFirstOrNull
|
||||
|
||||
suspend fun PointerInputScope.inspectDragGestures(
|
||||
onDragStart: (down: PointerInputChange) -> Unit = {},
|
||||
onDragEnd: (change: PointerInputChange) -> Unit = {},
|
||||
onDragCancel: () -> Unit = {},
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
|
||||
|
||||
val down = awaitFirstDown(false)
|
||||
|
||||
onDragStart(down)
|
||||
onDrag(initialDown, Offset.Zero)
|
||||
val upEvent =
|
||||
drag(
|
||||
pointerId = initialDown.id,
|
||||
onDrag = { onDrag(it, it.positionChange()) }
|
||||
)
|
||||
if (upEvent == null) {
|
||||
onDragCancel()
|
||||
} else {
|
||||
onDragEnd(upEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun AwaitPointerEventScope.drag(
|
||||
pointerId: PointerId,
|
||||
onDrag: (PointerInputChange) -> Unit
|
||||
): PointerInputChange? {
|
||||
val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
|
||||
if (isPointerUp) {
|
||||
return null
|
||||
}
|
||||
var pointer = pointerId
|
||||
while (true) {
|
||||
val change = awaitDragOrUp(pointer) ?: return null
|
||||
if (change.isConsumed) {
|
||||
return null
|
||||
}
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
return change
|
||||
}
|
||||
onDrag(change)
|
||||
pointer = change.id
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
|
||||
pointerId: PointerId
|
||||
): PointerInputChange? {
|
||||
var pointer = pointerId
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
|
||||
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||
if (otherDown == null) {
|
||||
return dragEvent
|
||||
} else {
|
||||
pointer = otherDown.id
|
||||
}
|
||||
} else {
|
||||
val hasDragged = dragEvent.previousPosition != dragEvent.position
|
||||
if (hasDragged) {
|
||||
return dragEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
class GestureDetector(
|
||||
private val airPodsService: AirPodsService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "GestureDetector"
|
||||
|
||||
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
||||
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
||||
|
||||
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
||||
private const val MIN_REQUIRED_EXTREMES = 3
|
||||
private const val MAX_REQUIRED_EXTREMES = 4
|
||||
|
||||
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
||||
}
|
||||
|
||||
val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
|
||||
|
||||
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private val horizontalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
private val verticalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private var prevHorizontal: Double = 0.0
|
||||
private var prevVertical: Double = 0.0
|
||||
|
||||
private val horizontalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val horizontalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val verticalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val verticalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
|
||||
private var lastPeakTime: Long = 0
|
||||
private val peakIntervals = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private val movementSpeedIntervals = Collections.synchronizedList(ArrayList<Long>())
|
||||
|
||||
private val peakThreshold = 400
|
||||
private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY
|
||||
private val rhythmConsistencyThreshold = 0.5
|
||||
|
||||
private var horizontalIncreasing: Boolean? = null
|
||||
private var verticalIncreasing: Boolean? = null
|
||||
|
||||
private val minConfidenceThreshold = 0.7
|
||||
|
||||
private var isRunning = false
|
||||
private var detectionJob: Job? = null
|
||||
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private var significantMotion = false
|
||||
private var lastSignificantMotionTime = 0L
|
||||
|
||||
init {
|
||||
while (horizontalAvgBuffer.size < 3) horizontalAvgBuffer.add(0.0)
|
||||
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
|
||||
}
|
||||
|
||||
fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
|
||||
if (isRunning) return
|
||||
|
||||
Log.d(TAG, "Starting gesture detection...")
|
||||
isRunning = true
|
||||
gestureDetectedCallback = onGestureDetected
|
||||
|
||||
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
|
||||
|
||||
clearData()
|
||||
|
||||
prevHorizontal = 0.0
|
||||
prevVertical = 0.0
|
||||
|
||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
while (isRunning) {
|
||||
delay(50)
|
||||
|
||||
val gesture = detectGestures()
|
||||
if (gesture != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
audio.playConfirmation(gesture)
|
||||
|
||||
gestureDetectedCallback?.invoke(gesture)
|
||||
stopDetection(doNotStop)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun stopDetection(doNotStop: Boolean = false) {
|
||||
if (!isRunning) return
|
||||
|
||||
Log.d(TAG, "Stopping gesture detection")
|
||||
isRunning = false
|
||||
|
||||
if (!doNotStop) airPodsService.stopHeadTracking()
|
||||
|
||||
detectionJob?.cancel()
|
||||
detectionJob = null
|
||||
gestureDetectedCallback = null
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun processHeadOrientation(horizontal: Int, vertical: Int) {
|
||||
if (!isRunning) return
|
||||
|
||||
if (abs(horizontal) > MAX_VALID_ORIENTATION_VALUE || abs(vertical) > MAX_VALID_ORIENTATION_VALUE) {
|
||||
Log.d(TAG, "Ignoring likely calibration data: h=$horizontal, v=$vertical")
|
||||
return
|
||||
}
|
||||
|
||||
val horizontalDelta = horizontal - prevHorizontal
|
||||
val verticalDelta = vertical - prevVertical
|
||||
|
||||
val significantHorizontal = abs(horizontalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
|
||||
val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
|
||||
|
||||
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
audio.playDirectional(isVertical = false, value = horizontalDelta)
|
||||
}
|
||||
significantMotion = true
|
||||
lastSignificantMotionTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta")
|
||||
}
|
||||
else if (significantVertical) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
audio.playDirectional(isVertical = true, value = verticalDelta)
|
||||
}
|
||||
significantMotion = true
|
||||
lastSignificantMotionTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Significant VERTICAL movement: $verticalDelta")
|
||||
}
|
||||
else if (significantMotion &&
|
||||
(System.currentTimeMillis() - lastSignificantMotionTime) > 300) {
|
||||
significantMotion = false
|
||||
}
|
||||
|
||||
prevHorizontal = horizontal.toDouble()
|
||||
prevVertical = vertical.toDouble()
|
||||
|
||||
val smoothHorizontal = applySmoothing(horizontal.toDouble(), horizontalAvgBuffer)
|
||||
val smoothVertical = applySmoothing(vertical.toDouble(), verticalAvgBuffer)
|
||||
|
||||
synchronized(horizontalBuffer) {
|
||||
horizontalBuffer.add(smoothHorizontal)
|
||||
if (horizontalBuffer.size > 100) horizontalBuffer.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(verticalBuffer) {
|
||||
verticalBuffer.add(smoothVertical)
|
||||
if (verticalBuffer.size > 100) verticalBuffer.removeAt(0)
|
||||
}
|
||||
|
||||
detectPeaksAndTroughs()
|
||||
}
|
||||
|
||||
private fun applySmoothing(newValue: Double, buffer: MutableList<Double>): Double {
|
||||
synchronized(buffer) {
|
||||
buffer.add(newValue)
|
||||
if (buffer.size > 3) buffer.removeAt(0)
|
||||
return buffer.average()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun detectPeaksAndTroughs() {
|
||||
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
||||
|
||||
val hValues = horizontalBuffer.takeLast(4)
|
||||
val vValues = verticalBuffer.takeLast(4)
|
||||
val hVariance = calculateVariance(hValues)
|
||||
val vVariance = calculateVariance(vValues)
|
||||
|
||||
processDirectionChanges(
|
||||
horizontalBuffer,
|
||||
horizontalIncreasing,
|
||||
hVariance,
|
||||
horizontalPeaks,
|
||||
horizontalTroughs
|
||||
)?.let { horizontalIncreasing = it }
|
||||
|
||||
processDirectionChanges(
|
||||
verticalBuffer,
|
||||
verticalIncreasing,
|
||||
vVariance,
|
||||
verticalPeaks,
|
||||
verticalTroughs
|
||||
)?.let { verticalIncreasing = it }
|
||||
}
|
||||
|
||||
private fun processDirectionChanges(
|
||||
buffer: List<Double>,
|
||||
isIncreasing: Boolean?,
|
||||
variance: Double,
|
||||
peaks: MutableList<Triple<Int, Double, Long>>,
|
||||
troughs: MutableList<Triple<Int, Double, Long>>
|
||||
): Boolean? {
|
||||
if (buffer.size < 2) return isIncreasing
|
||||
|
||||
val current = buffer.last()
|
||||
val prev = buffer[buffer.size - 2]
|
||||
var increasing = isIncreasing ?: (current > prev)
|
||||
|
||||
val dynamicThreshold = max(50.0, min(directionChangeThreshold.toDouble(), variance / 3))
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (increasing && current < prev - dynamicThreshold) {
|
||||
if (abs(prev) > peakThreshold) {
|
||||
peaks.add(Triple(buffer.size - 1, prev, now))
|
||||
if (lastPeakTime > 0) {
|
||||
val interval = (now - lastPeakTime) / 1000.0
|
||||
val timeDiff = now - lastPeakTime
|
||||
|
||||
synchronized(peakIntervals) {
|
||||
peakIntervals.add(interval)
|
||||
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(movementSpeedIntervals) {
|
||||
movementSpeedIntervals.add(timeDiff)
|
||||
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
|
||||
}
|
||||
}
|
||||
lastPeakTime = now
|
||||
}
|
||||
increasing = false
|
||||
} else if (!increasing && current > prev + dynamicThreshold) {
|
||||
if (abs(prev) > peakThreshold) {
|
||||
troughs.add(Triple(buffer.size - 1, prev, now))
|
||||
|
||||
if (lastPeakTime > 0) {
|
||||
val interval = (now - lastPeakTime) / 1000.0
|
||||
val timeDiff = now - lastPeakTime
|
||||
|
||||
synchronized(peakIntervals) {
|
||||
peakIntervals.add(interval)
|
||||
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(movementSpeedIntervals) {
|
||||
movementSpeedIntervals.add(timeDiff)
|
||||
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
|
||||
}
|
||||
}
|
||||
lastPeakTime = now
|
||||
}
|
||||
increasing = true
|
||||
}
|
||||
|
||||
return increasing
|
||||
}
|
||||
|
||||
private fun calculateVariance(values: List<Double>): Double {
|
||||
if (values.size <= 1) return 0.0
|
||||
|
||||
val mean = values.average()
|
||||
val squaredDiffs = values.map { (it - mean) * (it - mean) }
|
||||
return squaredDiffs.average()
|
||||
}
|
||||
|
||||
|
||||
private fun calculateRhythmConsistency(): Double {
|
||||
if (peakIntervals.size < 2) return 0.0
|
||||
|
||||
val meanInterval = peakIntervals.average()
|
||||
if (meanInterval == 0.0) return 0.0
|
||||
|
||||
val variances = peakIntervals.map { (it / meanInterval - 1.0).pow(2) }
|
||||
val consistency = 1.0 - min(1.0, variances.average() / rhythmConsistencyThreshold)
|
||||
return max(0.0, consistency)
|
||||
}
|
||||
|
||||
|
||||
private fun calculateConfidenceScore(extremes: List<Triple<Int, Double, Long>>, isVertical: Boolean): Double {
|
||||
if (extremes.size < getRequiredExtremes()) return 0.0
|
||||
|
||||
val sortedExtremes = extremes.sortedBy { it.first }
|
||||
|
||||
val recent = sortedExtremes.takeLast(getRequiredExtremes())
|
||||
|
||||
val avgAmplitude = recent.map { abs(it.second) }.average()
|
||||
val amplitudeFactor = min(1.0, avgAmplitude / 600)
|
||||
|
||||
val rhythmFactor = calculateRhythmConsistency()
|
||||
|
||||
val signs = recent.map { if (it.second > 0) 1 else -1 }
|
||||
val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] }
|
||||
val alternationFactor = if (alternating) 1.0 else 0.5
|
||||
|
||||
val isolationFactor = if (isVertical) {
|
||||
val vertAmplitude = recent.map { abs(it.second) }.average()
|
||||
val horizVals = horizontalBuffer.takeLast(recent.size * 2)
|
||||
val horizAmplitude = horizVals.map { abs(it) }.average()
|
||||
min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2)
|
||||
} else {
|
||||
val horizAmplitude = recent.map { abs(it.second) }.average()
|
||||
val vertVals = verticalBuffer.takeLast(recent.size * 2)
|
||||
val vertAmplitude = vertVals.map { abs(it) }.average()
|
||||
min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2)
|
||||
}
|
||||
|
||||
return (
|
||||
amplitudeFactor * 0.4 +
|
||||
rhythmFactor * 0.2 +
|
||||
alternationFactor * 0.2 +
|
||||
isolationFactor * 0.2
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRequiredExtremes(): Int {
|
||||
if (movementSpeedIntervals.isEmpty()) return MIN_REQUIRED_EXTREMES
|
||||
|
||||
val avgInterval = movementSpeedIntervals.average()
|
||||
Log.d(TAG, "Average movement interval: $avgInterval ms")
|
||||
|
||||
return if (avgInterval < FAST_MOVEMENT_THRESHOLD) {
|
||||
MAX_REQUIRED_EXTREMES
|
||||
} else {
|
||||
MIN_REQUIRED_EXTREMES
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectGestures(): Boolean? {
|
||||
val requiredExtremes = getRequiredExtremes()
|
||||
Log.d(TAG, "Current required extremes: $requiredExtremes")
|
||||
|
||||
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
|
||||
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
|
||||
|
||||
val confidence = calculateConfidenceScore(allExtremes, isVertical = true)
|
||||
|
||||
Log.d(TAG, "Vertical motion confidence: $confidence (need $minConfidenceThreshold)")
|
||||
|
||||
if (confidence >= minConfidenceThreshold) {
|
||||
Log.d(TAG, "\"Yes\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) {
|
||||
val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first }
|
||||
|
||||
val confidence = calculateConfidenceScore(allExtremes, isVertical = false)
|
||||
|
||||
Log.d(TAG, "Horizontal motion confidence: $confidence (need $minConfidenceThreshold)")
|
||||
|
||||
if (confidence >= minConfidenceThreshold) {
|
||||
Log.d(TAG, "\"No\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun clearData() {
|
||||
horizontalBuffer.clear()
|
||||
verticalBuffer.clear()
|
||||
horizontalPeaks.clear()
|
||||
horizontalTroughs.clear()
|
||||
verticalPeaks.clear()
|
||||
verticalTroughs.clear()
|
||||
peakIntervals.clear()
|
||||
movementSpeedIntervals.clear()
|
||||
horizontalIncreasing = null
|
||||
verticalIncreasing = null
|
||||
lastPeakTime = 0
|
||||
significantMotion = false
|
||||
lastSignificantMotionTime = 0L
|
||||
}
|
||||
|
||||
private fun Double.pow(exponent: Int): Double = this.pow(exponent.toDouble())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("PrivatePropertyName")
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.SoundPool
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class GestureFeedback(context: Context) {
|
||||
|
||||
private val TAG = "GestureFeedback"
|
||||
|
||||
private val soundsLoaded = AtomicBoolean(false)
|
||||
|
||||
private val soundPool = SoundPool.Builder()
|
||||
.setMaxStreams(3)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
|
||||
private var soundId = 0
|
||||
private var confirmYesId = 0
|
||||
private var confirmNoId = 0
|
||||
|
||||
private var lastHorizontalTime = 0L
|
||||
private var lastLeftTime = 0L
|
||||
private var lastRightTime = 0L
|
||||
|
||||
private var lastVerticalTime = 0L
|
||||
private var lastUpTime = 0L
|
||||
private var lastDownTime = 0L
|
||||
|
||||
private val MIN_TIME_BETWEEN_SOUNDS = 150L
|
||||
private val MIN_TIME_BETWEEN_DIRECTION = 200L
|
||||
|
||||
private var currentHorizontalStreamId = 0
|
||||
private var currentVerticalStreamId = 0
|
||||
|
||||
|
||||
private val LEFT_VOLUME = Pair(1.0f, 0.0f)
|
||||
private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
|
||||
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
|
||||
|
||||
init {
|
||||
soundId = soundPool.load(context, R.raw.blip_no, 1)
|
||||
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
|
||||
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
|
||||
|
||||
soundPool.setOnLoadCompleteListener { _, _, _ ->
|
||||
Log.d(TAG, "Sounds loaded")
|
||||
soundsLoaded.set(true)
|
||||
|
||||
soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun playDirectional(isVertical: Boolean, value: Double) {
|
||||
if (!soundsLoaded.get()) {
|
||||
Log.d(TAG, "Sounds not yet loaded, skipping playback")
|
||||
return
|
||||
}
|
||||
|
||||
val now = SystemClock.uptimeMillis()
|
||||
|
||||
if (isVertical) {
|
||||
val isUp = value > 0
|
||||
|
||||
if (now - lastVerticalTime < MIN_TIME_BETWEEN_SOUNDS) {
|
||||
Log.d(TAG, "Skipping vertical sound due to general vertical debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (isUp && now - lastUpTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping UP sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isUp && now - lastDownTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping DOWN sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentVerticalStreamId > 0) {
|
||||
soundPool.stop(currentVerticalStreamId)
|
||||
}
|
||||
|
||||
val (leftVol, rightVol) = VERTICAL_VOLUME
|
||||
|
||||
currentVerticalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing VERTICAL sound: ${if (isUp) "UP" else "DOWN"} - streamID=$currentVerticalStreamId")
|
||||
|
||||
lastVerticalTime = now
|
||||
if (isUp) {
|
||||
lastUpTime = now
|
||||
} else {
|
||||
lastDownTime = now
|
||||
}
|
||||
} else {
|
||||
if (now - lastHorizontalTime < MIN_TIME_BETWEEN_SOUNDS) {
|
||||
Log.d(TAG, "Skipping horizontal sound due to general horizontal debounce")
|
||||
return
|
||||
}
|
||||
|
||||
val isRight = value > 0
|
||||
|
||||
if (isRight && now - lastRightTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping RIGHT sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRight && now - lastLeftTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping LEFT sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHorizontalStreamId > 0) {
|
||||
soundPool.stop(currentHorizontalStreamId)
|
||||
}
|
||||
|
||||
val (leftVol, rightVol) = if (isRight) RIGHT_VOLUME else LEFT_VOLUME
|
||||
|
||||
currentHorizontalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing HORIZONTAL sound: ${if (isRight) "RIGHT" else "LEFT"} - streamID=$currentHorizontalStreamId")
|
||||
|
||||
lastHorizontalTime = now
|
||||
if (isRight) {
|
||||
lastRightTime = now
|
||||
} else {
|
||||
lastLeftTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playConfirmation(isYes: Boolean) {
|
||||
if (currentHorizontalStreamId > 0) {
|
||||
soundPool.stop(currentHorizontalStreamId)
|
||||
}
|
||||
if (currentVerticalStreamId > 0) {
|
||||
soundPool.stop(currentVerticalStreamId)
|
||||
}
|
||||
|
||||
val soundId = if (isYes) confirmYesId else confirmNoId
|
||||
if (soundId != 0 && soundsLoaded.get()) {
|
||||
val streamId = soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
|
||||
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
||||
|
||||
object HeadTracking {
|
||||
private val _orientation = MutableStateFlow(Orientation())
|
||||
val orientation = _orientation.asStateFlow()
|
||||
|
||||
private val _acceleration = MutableStateFlow(Acceleration())
|
||||
val acceleration = _acceleration.asStateFlow()
|
||||
|
||||
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
|
||||
private var isCalibrated = false
|
||||
private var o1Neutral = 19000
|
||||
private var o2Neutral = 0
|
||||
private var o3Neutral = 0
|
||||
|
||||
private const val CALIBRATION_SAMPLE_COUNT = 10
|
||||
private const val ORIENTATION_OFFSET = 5500
|
||||
|
||||
fun processPacket(packet: ByteArray) {
|
||||
val o1 = bytesToInt(packet[43], packet[44])
|
||||
val o2 = bytesToInt(packet[45], packet[46])
|
||||
val o3 = bytesToInt(packet[47], packet[48])
|
||||
|
||||
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
|
||||
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
|
||||
|
||||
if (!isCalibrated) {
|
||||
calibrationSamples.add(Triple(o1, o2, o3))
|
||||
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
|
||||
calibrate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val orientation = calculateOrientation(o1, o2, o3)
|
||||
_orientation.value = orientation
|
||||
|
||||
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
|
||||
}
|
||||
|
||||
private fun calibrate() {
|
||||
if (calibrationSamples.size < 3) return
|
||||
|
||||
// Add offset during calibration
|
||||
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
|
||||
isCalibrated = true
|
||||
}
|
||||
|
||||
@Suppress("UnusedVariable")
|
||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||
if (!isCalibrated) return Orientation()
|
||||
|
||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||
|
||||
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
|
||||
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
|
||||
|
||||
return Orientation(pitch, yaw)
|
||||
}
|
||||
|
||||
private fun bytesToInt(b1: Byte, b2: Byte): Int {
|
||||
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
calibrationSamples.clear()
|
||||
isCalibrated = false
|
||||
_orientation.value = Orientation()
|
||||
_acceleration.value = Acceleration()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user