mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-29 06:10:52 +00:00
Compare commits
293 Commits
v0.0.3
...
linux-v0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a01e16792a | ||
|
|
253ed65afc | ||
|
|
b1f3856d0f | ||
|
|
99a689a0f8 | ||
|
|
0c9a2bd743 | ||
|
|
6585cf648c | ||
|
|
99e5b71676 | ||
|
|
47f02136cd | ||
|
|
29c422528a | ||
|
|
9d10eed85d | ||
|
|
9f7f4347ec | ||
|
|
b7cd80edac | ||
|
|
2049431817 | ||
|
|
23cf5728e9 | ||
|
|
381b09725b | ||
|
|
3853e8ec9a | ||
|
|
bf6630dbd1 | ||
|
|
a2cda688d4 | ||
|
|
934df2419a | ||
|
|
64470c4d34 | ||
|
|
fa8bc11060 | ||
|
|
c5a824c384 | ||
|
|
99beeb5907 | ||
|
|
17b545481e | ||
|
|
320964e9d7 | ||
|
|
51b3d4692a | ||
|
|
3a0cc2e7f4 | ||
|
|
26cee5c8a5 | ||
|
|
a007d9cd80 | ||
|
|
b47469803b | ||
|
|
925c930073 | ||
|
|
ccee82026d | ||
|
|
99940b98ae | ||
|
|
fec226336c | ||
|
|
e5c2419ef6 | ||
|
|
221680ff32 | ||
|
|
9da4c938ed | ||
|
|
7dd029faa6 | ||
|
|
ae5a701257 | ||
|
|
0f04290fba | ||
|
|
b0561e96df | ||
|
|
c0ae061cc7 | ||
|
|
cf2a242d7c | ||
|
|
43bfbda21e | ||
|
|
34f60699b8 | ||
|
|
a38ebd213b | ||
|
|
142e0c5e37 | ||
|
|
a8963ecef5 | ||
|
|
53f5626914 | ||
|
|
89782d9b7c | ||
|
|
283f841855 | ||
|
|
4c8ebe27bc | ||
|
|
bc5bcd81b3 | ||
|
|
b72680a1c4 | ||
|
|
816048f1f8 | ||
|
|
d6b98b70e8 | ||
|
|
f62a57c888 | ||
|
|
f7bb1ce6fc | ||
|
|
9bf3e64b20 | ||
|
|
217455fecb | ||
|
|
84d5fa466e | ||
|
|
8eb6eba049 | ||
|
|
e437572355 | ||
|
|
f062eb43b3 | ||
|
|
28ffd217d6 | ||
|
|
b5d1ad0dd5 | ||
|
|
a6d6e0e13c | ||
|
|
fe774d565d | ||
|
|
120681541f | ||
|
|
784b7a2cfa | ||
|
|
d7a96c9fc5 | ||
|
|
b732c66962 | ||
|
|
f5742618c7 | ||
|
|
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 |
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
paths:
|
||||
- 'android/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
@@ -11,6 +13,10 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
custom_notes:
|
||||
description: 'Custom updates to add to What''s Changed section'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
@@ -43,7 +49,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Export APK_NAME for later use
|
||||
run: echo "APK_NAME=ALN-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
- name: Rename .apk file
|
||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
||||
- name: Decode keystore file
|
||||
@@ -63,12 +69,32 @@ jobs:
|
||||
run: |
|
||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
||||
echo "::set-output name=commits::${COMMITS}"
|
||||
- name: Prepare release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
# Create a temporary file for release notes
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
# Process custom notes if they exist
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
||||
|
||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
||||
# Already formatted, use as is
|
||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
else
|
||||
# Add bullet point formatting
|
||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
- name: Zip root-module directory
|
||||
run: zip -r -0 ../btl2capfix.zip * --verbose
|
||||
working-directory: root-module
|
||||
run: sh ./build-magisk-module.sh
|
||||
- name: Delete release if exist then create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" -n "${{ steps.get-commits.outputs.commits }}" --generate-notes
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
||||
87
.github/workflows/ci-linux-rust.yml
vendored
Normal file
87
.github/workflows/ci-linux-rust.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Linux Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- linux/rust
|
||||
tags:
|
||||
- 'linux-v*'
|
||||
paths:
|
||||
- 'linux-rust/**'
|
||||
- '.github/workflows/linux-build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2
|
||||
|
||||
- name: Install AppImage tools
|
||||
run: |
|
||||
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
|
||||
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
|
||||
chmod +x /usr/local/bin/{appimagetool,linuxdeploy}
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
linux-rust/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }}
|
||||
|
||||
- name: Build AppImage and Binary
|
||||
working-directory: linux-rust
|
||||
run: |
|
||||
cargo build --release --verbose
|
||||
just
|
||||
mkdir -p dist
|
||||
cp target/release/librepods dist/librepods
|
||||
mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: librepods-x86_64.AppImage
|
||||
path: linux-rust/dist/librepods-x86_64.AppImage
|
||||
|
||||
- name: Upload binary artifact
|
||||
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: librepods
|
||||
path: linux-rust/dist/librepods
|
||||
|
||||
- name: Create tarball for Flatpak
|
||||
if: startsWith(github.ref, 'refs/tags/linux-v')
|
||||
working-directory: linux-rust
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#linux-v}"
|
||||
just tarball "${VERSION}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV
|
||||
|
||||
- name: Create GitHub Release (AppImage + binary + source)
|
||||
if: startsWith(github.ref, 'refs/tags/linux-v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz
|
||||
linux-rust/dist/librepods-x86_64.AppImage
|
||||
linux-rust/dist/librepods
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
37
.github/workflows/ci-linux.yml
vendored
Normal file
37
.github/workflows/ci-linux.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Build LibrePods Linux
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# push:
|
||||
# branches:
|
||||
# - '*'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
|
||||
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Build project
|
||||
working-directory: linux
|
||||
run: |
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G Ninja
|
||||
ninja
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: librepods-linux
|
||||
path: linux/build/librepods
|
||||
469
.gitignore
vendored
469
.gitignore
vendored
@@ -1,13 +1,17 @@
|
||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
||||
wak.toml
|
||||
log.txt
|
||||
btl2capfix.zip
|
||||
|
||||
root-module-manual
|
||||
.vscode
|
||||
testing.py
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
|
||||
# Android Template
|
||||
|
||||
|
||||
### Android ###
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
@@ -42,19 +46,284 @@ google-services.json
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Python Template
|
||||
### Android Patch ###
|
||||
gen-external-apklibs
|
||||
|
||||
# Replacement of .externalNativeBuild directories introduced
|
||||
# with Android Studio 3.5.
|
||||
|
||||
### C++ ###
|
||||
# Prerequisites
|
||||
*.d
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Compiled Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
### CLion ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### CLion Patch ###
|
||||
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||
|
||||
# *.iml
|
||||
# modules.xml
|
||||
# .idea/misc.xml
|
||||
# *.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||
.idea/**/sonarlint/
|
||||
|
||||
# SonarQube Plugin
|
||||
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||
.idea/**/sonarIssues.xml
|
||||
|
||||
# Markdown Navigator plugin
|
||||
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||
.idea/**/markdown-navigator.xml
|
||||
.idea/**/markdown-navigator-enh.xml
|
||||
.idea/**/markdown-navigator/
|
||||
|
||||
# Cache file creation bug
|
||||
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||
.idea/$CACHE_FILE$
|
||||
|
||||
# CodeStream plugin
|
||||
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||
.idea/codestream.xml
|
||||
|
||||
# Azure Toolkit for IntelliJ plugin
|
||||
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
|
||||
.idea/**/azureSettings.xml
|
||||
|
||||
### Kotlin ###
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### PyCharm ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
|
||||
# AWS User-specific
|
||||
|
||||
# Generated files
|
||||
|
||||
# Sensitive or high-churn files
|
||||
|
||||
# Gradle
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
|
||||
# Mongo Explorer plugin
|
||||
|
||||
# File-based project format
|
||||
|
||||
# IntelliJ
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
|
||||
# JIRA plugin
|
||||
|
||||
# Cursive Clojure plugin
|
||||
|
||||
# SonarLint plugin
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
|
||||
# Editor-based Rest Client
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
|
||||
### PyCharm Patch ###
|
||||
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||
|
||||
# *.iml
|
||||
# modules.xml
|
||||
# .idea/misc.xml
|
||||
# *.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||
|
||||
# SonarQube Plugin
|
||||
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||
|
||||
# Markdown Navigator plugin
|
||||
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||
|
||||
# Cache file creation bug
|
||||
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||
|
||||
# CodeStream plugin
|
||||
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||
|
||||
# Azure Toolkit for IntelliJ plugin
|
||||
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
|
||||
|
||||
### Python ###
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
# *.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
@@ -102,7 +371,6 @@ cover/
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
@@ -152,10 +420,8 @@ ipython_config.py
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
@@ -206,3 +472,190 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### Python Patch ###
|
||||
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
|
||||
### Qt ###
|
||||
# C++ objects and libs
|
||||
*.so.*
|
||||
|
||||
# Qt-es
|
||||
object_script.*.Release
|
||||
object_script.*.Debug
|
||||
*_plugin_import.cpp
|
||||
/.qmake.cache
|
||||
/.qmake.stash
|
||||
*.pro.user
|
||||
*.pro.user.*
|
||||
*.qbs.user
|
||||
*.qbs.user.*
|
||||
*.moc
|
||||
moc_*.cpp
|
||||
moc_*.h
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
*.qmlc
|
||||
*.jsc
|
||||
Makefile*
|
||||
*build-*
|
||||
*.qm
|
||||
*.prl
|
||||
|
||||
# Qt unit tests
|
||||
target_wrapper.*
|
||||
|
||||
# QtCreator
|
||||
*.autosave
|
||||
|
||||
# QtCreator Qml
|
||||
*.qmlproject.user
|
||||
*.qmlproject.user.*
|
||||
|
||||
# QtCreator CMake
|
||||
CMakeLists.txt.user*
|
||||
|
||||
# QtCreator 4.8< compilation database
|
||||
compile_commands.json
|
||||
|
||||
# QtCreator local machine specific files for imported projects
|
||||
*creator.user*
|
||||
|
||||
*_qmlcache.qrc
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
### AndroidStudio ###
|
||||
# Covers files to be ignored for android development using Android Studio.
|
||||
|
||||
# Built application files
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
|
||||
# Gradle files
|
||||
.gradle
|
||||
|
||||
# Signing files
|
||||
.signing/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
|
||||
# Android Studio
|
||||
/*/build/
|
||||
/*/local.properties
|
||||
/*/out
|
||||
/*/*/build
|
||||
/*/*/production
|
||||
.navigation/
|
||||
*.ipr
|
||||
*.swp
|
||||
|
||||
# Keystore files
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Android Patch
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
# NDK
|
||||
obj/
|
||||
|
||||
# IntelliJ IDEA
|
||||
/out/
|
||||
|
||||
# User-specific configurations
|
||||
.idea/caches/
|
||||
.idea/libraries/
|
||||
.idea/shelf/
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/.name
|
||||
.idea/compiler.xml
|
||||
.idea/copyright/profiles_settings.xml
|
||||
.idea/encodings.xml
|
||||
.idea/misc.xml
|
||||
.idea/modules.xml
|
||||
.idea/scopes/scope_settings.xml
|
||||
.idea/dictionaries
|
||||
.idea/vcs.xml
|
||||
.idea/jsLibraryMappings.xml
|
||||
.idea/datasources.xml
|
||||
.idea/dataSources.ids
|
||||
.idea/sqlDataSources.xml
|
||||
.idea/dynamic.xml
|
||||
.idea/uiDesigner.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/gradle.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Legacy Eclipse project files
|
||||
.classpath
|
||||
.project
|
||||
.cproject
|
||||
.settings/
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
|
||||
# Package Files #
|
||||
|
||||
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
|
||||
|
||||
## Plugin-specific files:
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
|
||||
# JIRA plugin
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/mongoSettings.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
|
||||
### AndroidStudio Patch ###
|
||||
|
||||
!/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
||||
linux/.qmlls.ini
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -229,50 +279,27 @@ duplicated thrice for some reason
|
||||
## Customize Transparency mode
|
||||
|
||||
```
|
||||
52 18 00
|
||||
For left bud
|
||||
[Enabled]
|
||||
12 18 00 [enabled]
|
||||
<left bud>
|
||||
[EQ1][EQ2][EQ3][EQ4][EQ5][EQ6][EQ7][EQ8]
|
||||
[Amplification]
|
||||
[Tone]
|
||||
[Conversation Boost]
|
||||
[Ambient Noise Reduction]
|
||||
00 0080 3F
|
||||
<same for the right bud>
|
||||
<repeat for right bud>
|
||||
```
|
||||
|
||||
<!-- demo packet
|
||||
52 18 00 00 00 00 00 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 00 00 80 3f 00 00 80 3f 00 00 80 3f 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 62 10 DA 41 00 00 80 3f 00 00 80 3f 00 00 80 3f
|
||||
|
||||
-->
|
||||
<!--
|
||||
5218 0000 0080 3F62 10DA 413D 0AF0 4160
|
||||
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
|
||||
1846 4293 1846 4206 9476 BF00 576E BB00
|
||||
0080 3F00 0080 3F62 10DA 413D 0AF0 4160
|
||||
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
|
||||
1846 4293 1846 4200 0080 BF00 576E BB00
|
||||
0080 3F00 0080 3F
|
||||
-->
|
||||
|
||||
<!--
|
||||
5218 0000 0000 0062 10DA 413D 0AF0 4160
|
||||
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
|
||||
1846 4293 1846 4206 9476 BF00 576E BB00
|
||||
0080 3F00 0080 3F62 10DA 413D 0AF0 4160
|
||||
E50C 42AC 9C1E 421B 2F29 429E 6F33 4293
|
||||
1846 4293 1846 4200 0080 BF00 576E BB00
|
||||
0080 3F00 0080 3F
|
||||
-->
|
||||
|
||||
All values are formatted as Little Endian from float values.
|
||||
| Data | Type | Value range |
|
||||
|---------------------|---------------|-------------|
|
||||
| Enabled | Little Endian | 0 or 1 |
|
||||
| EQ | Little Endian | 0 to 100 |
|
||||
| Amplification | Little Endian | -1 to 1 |
|
||||
| Tone | Little Endian | -1 to 1 |
|
||||
| Conversation Boost | Little Endian | 0 or 1 |
|
||||
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.
|
||||
@@ -348,32 +375,39 @@ The packets sent (based on the previous states) are as follows:
|
||||
|
||||
> *i do hate apple for not hardcoding these, like there are literally only 4^2 - ${\binom{4}{1}}$ - $\binom{4}{2}$*
|
||||
|
||||
# Miscellaneous/Unknown
|
||||
# Head Tracking
|
||||
|
||||
## Request something (Probably Head Positions)
|
||||
## Start Tracking
|
||||
|
||||
This packet initiates head tracking. When sent, the AirPods begin streaming head tracking data (e.g. orientation and acceleration) for live plotting and analysis.
|
||||
|
||||
```plaintext
|
||||
<!-- 04 00 04 00 17 00 00 00 10 00 11 00 08 7C 10 02 42 0B 08 4E 10 02 1A 05 01 40 9C 00 00 -->
|
||||
OR
|
||||
04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00
|
||||
```
|
||||
|
||||
Example packet
|
||||
## Stop Tracking
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 43 00 08 ec 07 10 01 1a 3c 0e 00 01 90 95 5d af 86 19 00 00 03 04 43 94 04 9e 6b 01 00 00 00 d5 a2 06 13 eb 13 03 00 f0 ff 01 00 67 83 67 83 67 83 fe ff fd ff 07 00 b3 01 9c 03 65 00 48 74 2c 37 fd 1e 00 00
|
||||
```
|
||||
|
||||
## Stop whatever was requested
|
||||
This packet stops the head tracking data stream.
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00
|
||||
```
|
||||
## Received Head Tracking Sensor Data
|
||||
|
||||
Once tracking is active, the AirPods stream sensor packets with the following common structure:
|
||||
|
||||
| Field | Offset | Length (bytes) |
|
||||
|--------------------------|--------|----------------|
|
||||
| orientation 1 | 43 | 2 |
|
||||
| orientation 2 | 45 | 2 |
|
||||
| orientation 3 | 47 | 2 |
|
||||
| Horizontal Acceleration | 51 | 2 |
|
||||
| Vertical Acceleration | 53 | 2 |
|
||||
|
||||
# LICENSE
|
||||
|
||||
AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
Copyright (C) 2024 Kavish Devar
|
||||
LibrePods - AirPods liberated from 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
|
||||
@@ -385,4 +419,4 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
## btl2capfix v0.0.3
|
||||
- ([#34](https://github.com/kavishdevar/aln/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
|
||||
|
||||
_[See more here](https://github.com/kavishdevar/aln/releases)_
|
||||
## LibrePods root module changelog
|
||||
_[See here](https://github.com/kavishdevar/librepods/releases)_
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Welcome to AirPods Like Normal contributing guide <!-- omit in toc -->
|
||||
# Welcome to LibrePods contributing guide <!-- omit in toc -->
|
||||
|
||||
Thank you for considering a contribution to AirPods Like Normal! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
|
||||
Thank you for considering a contribution to LibrePods! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
|
||||
|
||||
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful.
|
||||
|
||||
@@ -25,11 +25,11 @@ To develop for the Android App, Android Studio is the preferred IDE. And you can
|
||||
|
||||
#### Create a new issue
|
||||
|
||||
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/aln/issues). If no relevant issue exists, open a new one and fill in the details.
|
||||
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/librepods/issues). If no relevant issue exists, open a new one and fill in the details.
|
||||
|
||||
#### Solve an issue
|
||||
|
||||
Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution.
|
||||
Browse our [issues list](https://github.com/kavishdevar/librepods/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution.
|
||||
|
||||
### Make Changes
|
||||
|
||||
@@ -37,7 +37,7 @@ Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an i
|
||||
|
||||
1. Fork the repository and clone it to your local environment.
|
||||
```
|
||||
git clone https://github.com/kavishdevar/aln.git
|
||||
git clone https://github.com/kavishdevar/librepods.git
|
||||
cd AirPods-Like-Normal
|
||||
```
|
||||
2. Create a working branch to start your changes.
|
||||
@@ -67,4 +67,4 @@ Once your PR is open, a team member will review it. They may ask questions or re
|
||||
|
||||
### Your PR is merged!
|
||||
|
||||
Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal.
|
||||
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.
|
||||
1
FUNDING.yml
Normal file
1
FUNDING.yml
Normal file
@@ -0,0 +1 @@
|
||||
github: kavishdevar
|
||||
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).
|
||||
142
README.md
142
README.md
@@ -1,85 +1,123 @@
|
||||
# ALN - AirPodsLikeNormal
|
||||
*Bringing AirPods' Apple-exclusive features on linux and android!*
|
||||

|
||||
|
||||
## [XDAForums Thread](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
|
||||
|
||||
## Tested device(s)
|
||||
- AirPods Pro 2
|
||||
|
||||
Other devices might work too. Features like ear detection and battery should be available for any AirPods! Although the app will show unsupported features/settings. I will not be able test any other devices than the ones I already have (i.e. the AirPods Pro 2).
|
||||
|
||||
## Features
|
||||
|
||||
Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list.
|
||||
[](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 — Deprecated, awaiting a rewrite!
|
||||
ANY ISSUES ABOUT THE LINUX VERSION WILL BE CLOSED.
|
||||
Check out the README file in [linux](/linux) folder for more info.
|
||||
## What is LibrePods?
|
||||
|
||||
This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more.
|
||||
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, 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 |
|
||||
|
||||
> Can I use aln without root?
|
||||
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with.
|
||||
|
||||
**No, it's not possible to use aln without root.** You will have to root your device if you want to use aln, and there is no way around it. **No exceptions.**
|
||||
## Key Features
|
||||
|
||||
### Screenshots
|
||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||
- **Battery Status**: Accurate battery levels
|
||||
- **Head Gestures**: Answer calls just by nodding your head
|
||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
||||
- **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!
|
||||
|
||||
Check out the new animations and transitions in the app!
|
||||
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
|
||||
|
||||
https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30
|
||||
## Platform Support
|
||||
|
||||
| | | |
|
||||
|-------------------|-------------------|-------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
### Linux
|
||||
|
||||
### Installation
|
||||
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
|
||||
|
||||
Currently, there's a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238) that prevents the app from working (upvote the issue - click the '+1' icon on the top right corner of IssueTracker).
|
||||
- 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]
|
||||
> Until Google merges the fix **you will only be able to use aln if you are rooted**. There are **no exceptions**, don't ask about it.
|
||||
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
|
||||
>
|
||||
> There are **no exceptions** to the root requirement until Google merges the fix.
|
||||
|
||||
In order to use aln you will have to install the module using your favorite root manager in OverlayFS mode (KernelSU, Apatch, or Magisk). If you don't know what this means, no support is provided: you will have to search by yourself on Google or ask in some Android rooting communities on Telegram.
|
||||
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
|
||||
|
||||
The module to install is available in the releases section under the name `btl2capfix.zip`.
|
||||
## Bluetooth DID (Device Identification) Hook
|
||||
|
||||
### Android – features
|
||||
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
|
||||
|
||||
#### Renaming the Airpods
|
||||
When you rename the Airpods using the app, you'll need to re-pair it with your phone. Currently, user-level apps cannot directly rename a Bluetooth device. After re-pairing, your phone will display the updated name!
|
||||
### Multi-device Connectivity
|
||||
|
||||
#### Noise Control Modes
|
||||
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.
|
||||
|
||||
- Active Noise Cancellation (ANC): Blocks external sounds using microphones and advanced algorithms for an immersive audio experience; ideal for noisy environments.
|
||||
- Transparency Mode: Allows external sounds to blend with audio for situational awareness; best for environments where you need to stay alert.
|
||||
- Off Mode: Disables noise control for a natural listening experience, conserving battery in quiet settings.
|
||||
- Adaptive Transparency: Dynamically reduces sudden loud noises while maintaining environmental awareness, adjusting seamlessly to fluctuating noise levels.
|
||||
### Accessibility Settings and Hearing Aid
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
|
||||
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
||||
|
||||
#### Conversational Awareness
|
||||
All hearing aid customizations can be done from Android, including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
|
||||
|
||||
Automatically lowers audio volume and enhances voices when you start speaking, making it easier to engage in conversations without removing your AirPods.
|
||||
To enable these features, enable App Settings -> `act as Apple Device`.
|
||||
|
||||
#### Automatic Ear Detection
|
||||
#### A few notes
|
||||
|
||||
Recognizes when the AirPods are in your ears to automatically play or pause audio and adjust functionality accordingly.
|
||||
- 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.
|
||||
|
||||
## Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md)
|
||||
- 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.
|
||||
|
||||
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#kavishdevar/librepods&Date)
|
||||
|
||||
# License
|
||||
|
||||
AirPodsLikeNormal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
Copyright (C) 2024 Kavish Devar
|
||||
LibrePods - AirPods liberated from 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
|
||||
@@ -92,3 +130,5 @@ GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
|
||||
1
android/.gitignore
vendored
1
android/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
crowdin.yml
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
|
||||
@@ -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 = 35
|
||||
namespace = "me.kavishdevar.librepods"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.aln"
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "0.0.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "0.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -39,6 +38,17 @@ android {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -57,11 +67,23 @@ dependencies {
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
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,21 +2,23 @@
|
||||
<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.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BATTERY_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.UPDATE_DEVICE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
@@ -24,6 +26,15 @@
|
||||
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"
|
||||
@@ -34,7 +45,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ALN"
|
||||
android:theme="@style/Theme.LibrePods"
|
||||
android:description="@string/app_description"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:targetApi="31">
|
||||
<receiver
|
||||
@@ -48,6 +60,7 @@
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/noise_control_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widgets.BatteryWidget"
|
||||
android:exported="false">
|
||||
@@ -60,26 +73,33 @@
|
||||
android:resource="@xml/battery_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".CustomDevice"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_custom_device"
|
||||
android:theme="@style/Theme.ALN">
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ALN">
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- <intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="librepods"
|
||||
android:host="add-magic-keys" />
|
||||
</intent-filter> -->
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".QuickSettingsDialogActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TransparentDialog"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".services.AirPodsService"
|
||||
android:enabled="true"
|
||||
@@ -96,7 +116,17 @@
|
||||
<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"
|
||||
@@ -107,6 +137,16 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
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,188 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothDevice.TRANSPORT_LE
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
import java.util.UUID
|
||||
|
||||
class CustomDevice : ComponentActivity() {
|
||||
@SuppressLint("MissingPermission", "CoroutineCreationDuringComposition")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ALNTheme {
|
||||
val connect = remember { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Custom Device", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
||||
val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
|
||||
// val device: BluetoothDevice = manager.adapter.getRemoteDevice("EC:D6:F4:3D:89:B8")
|
||||
val device: BluetoothDevice = manager.adapter.getRemoteDevice("E7:48:92:3B:7D:A5")
|
||||
// val socket = device.createInsecureL2capChannel(31)
|
||||
|
||||
// val batteryLevel = remember { mutableStateOf("") }
|
||||
// socket.outputStream.write(byteArrayOf(0x12,0x3B,0x00,0x02, 0x00))
|
||||
// socket.outputStream.write(byteArrayOf(0x12, 0x3A, 0x00, 0x01, 0x00, 0x08,0x01))
|
||||
|
||||
val gatt = device.connectGatt(this, true, object: BluetoothGattCallback() {
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
// Step 2: Iterate through the services and characteristics
|
||||
gatt.services.forEach { service ->
|
||||
Log.d("GATT", "Service UUID: ${service.uuid}")
|
||||
service.characteristics.forEach { characteristic ->
|
||||
characteristic.descriptors.forEach { descriptor ->
|
||||
Log.d("GATT", " Descriptor UUID: ${descriptor.uuid}: ${gatt.readDescriptor(descriptor)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (newState == BluetoothGatt.STATE_CONNECTED) {
|
||||
Log.d("GATT", "Connected to GATT server")
|
||||
gatt.discoverServices() // Discover services after connection
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int
|
||||
) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.d("BLE", "Write successful for UUID: ${characteristic.uuid}")
|
||||
} else {
|
||||
Log.e("BLE", "Write failed for UUID: ${characteristic.uuid}, status: $status")
|
||||
}
|
||||
}
|
||||
}, TRANSPORT_LE, 1)
|
||||
|
||||
if (connect.value) {
|
||||
try {
|
||||
gatt.connect()
|
||||
}
|
||||
catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
connect.value = false
|
||||
}
|
||||
|
||||
Column (
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
)
|
||||
{
|
||||
Button(
|
||||
onClick = { connect.value = true }
|
||||
)
|
||||
{
|
||||
Text("Connect")
|
||||
}
|
||||
|
||||
Button(onClick = {
|
||||
val characteristicUuid = "94110001-6D9B-4225-A4F1-6A4A7F01B0DE"
|
||||
val value = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00 ,0x00 ,0x01)
|
||||
sendWriteRequest(gatt, characteristicUuid, value)
|
||||
|
||||
}) {
|
||||
Text("batteryLevel.value")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun sendWriteRequest(
|
||||
gatt: BluetoothGatt,
|
||||
characteristicUuid: String,
|
||||
value: ByteArray
|
||||
) {
|
||||
// Retrieve the service containing the characteristic
|
||||
val service = gatt.services.find { service ->
|
||||
service.characteristics.any { it.uuid.toString() == characteristicUuid }
|
||||
}
|
||||
|
||||
if (service == null) {
|
||||
Log.e("GATT", "Service containing characteristic UUID $characteristicUuid not found.")
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the characteristic
|
||||
val characteristic = service.getCharacteristic(UUID.fromString(characteristicUuid))
|
||||
if (characteristic == null) {
|
||||
Log.e("GATT", "Characteristic with UUID $characteristicUuid not found.")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Send the write request
|
||||
val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
|
||||
} else {
|
||||
gatt.writeCharacteristic(characteristic)
|
||||
}
|
||||
Log.d("GATT", "Write request sent $success to UUID: $characteristicUuid")
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import me.kavishdevar.aln.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.aln.screens.AppSettingsScreen
|
||||
import me.kavishdevar.aln.screens.DebugScreen
|
||||
import me.kavishdevar.aln.screens.LongPress
|
||||
import me.kavishdevar.aln.screens.RenameScreen
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
import me.kavishdevar.aln.utils.CrossDevice
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ALNTheme {
|
||||
getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor",
|
||||
MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply()
|
||||
Main()
|
||||
startService(Intent(this, AirPodsService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val isConnected = remember { mutableStateOf(false) }
|
||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||
val permissionState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE"
|
||||
)
|
||||
)
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
if (permissionState.allPermissionsGranted) {
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
|
||||
connectionStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.AIRPODS_CONNECTED) {
|
||||
Log.d("MainActivity", "AirPods Connected intent received")
|
||||
isConnected.value = true
|
||||
}
|
||||
else if (intent.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
|
||||
Log.d("MainActivity", "AirPods Disconnected intent received")
|
||||
isRemotelyConnected.value = CrossDevice.isAvailable
|
||||
isConnected.value = false
|
||||
}
|
||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
Log.d("MainActivity", "Disconnect Receivers intent received")
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "CrossDeviceIsAvailable") {
|
||||
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
|
||||
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
|
||||
}
|
||||
}
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
|
||||
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
|
||||
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
|
||||
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
Log.d("MainActivity", "Registering Receiver")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.registerReceiver(connectionStatusReceiver, filter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(connectionStatusReceiver, filter)
|
||||
}
|
||||
Log.d("MainActivity", "Registered Receiver")
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "settings",
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
) + scaleIn(
|
||||
initialScale = 0.85f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
) + scaleOut(
|
||||
targetScale = 0.85f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
) + scaleIn(
|
||||
initialScale = 0.85f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
) + scaleOut(
|
||||
targetScale = 0.85f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable("settings") {
|
||||
if (airPodsService.value != null) {
|
||||
AirPodsSettingsScreen(
|
||||
dev = airPodsService.value?.device,
|
||||
service = airPodsService.value!!,
|
||||
navController = navController,
|
||||
isConnected = isConnected.value,
|
||||
isRemotelyConnected = isRemotelyConnected.value
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("debug") {
|
||||
DebugScreen(navController = navController)
|
||||
}
|
||||
composable("long_press/{bud}") { navBackStackEntry ->
|
||||
LongPress(
|
||||
navController = navController,
|
||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
||||
)
|
||||
}
|
||||
composable("rename") { navBackStackEntry ->
|
||||
RenameScreen(navController)
|
||||
}
|
||||
composable("app_settings") {
|
||||
AppSettingsScreen(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceConnection = remember {
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService.value = binder.getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (airPodsService.value?.isConnectedLocally == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
Column (
|
||||
modifier = Modifier.padding(24.dp),
|
||||
){
|
||||
val textToShow = if (permissionState.shouldShowRationale) {
|
||||
// If the user has denied the permission but not permanently, explain why it's needed.
|
||||
"Please enable Bluetooth and Notification permissions to use the app. The Nearby Devices is required to connect to your AirPods, and the notification is required to show the AirPods battery status."
|
||||
} else {
|
||||
// If the user has permanently denied the permission, inform them to enable it in settings.
|
||||
"Please enable Bluetooth and Notification permissions in the app settings to use the app."
|
||||
}
|
||||
Text(textToShow)
|
||||
Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
|
||||
Text("Request permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.accessibility).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.tone_volume),
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
|
||||
.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
|
||||
}
|
||||
|
||||
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
|
||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Press Speed",
|
||||
options = pressSpeedOptions,
|
||||
selectedOption = selectedPressSpeed,
|
||||
onOptionSelected = {
|
||||
selectedPressSpeed = it
|
||||
service.setPressSpeed(pressSpeedOptions.indexOf(it))
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
|
||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Press and Hold Duration",
|
||||
options = pressAndHoldDurationOptions,
|
||||
selectedOption = selectedPressAndHoldDuration,
|
||||
onOptionSelected = {
|
||||
selectedPressAndHoldDuration = it
|
||||
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
|
||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Volume Swipe Speed",
|
||||
options = volumeSwipeSpeedOptions,
|
||||
selectedOption = selectedVolumeSwipeSpeed,
|
||||
onOptionSelected = {
|
||||
selectedVolumeSwipeSpeed = it
|
||||
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DropdownMenuComponent(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
textColor: Color
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = true }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = selectedOption,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
text = { Text(text = option) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AccessibilitySettingsPreview() {
|
||||
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("adaptive_strength")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sliderValue.floatValue) {
|
||||
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Slider(
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Less Noise",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = "More Noise",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AdaptiveStrengthSliderPreview() {
|
||||
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.audio).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
|
||||
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive_audio),
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
|
||||
.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive_audio_description),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp, top = 2.dp)
|
||||
.padding(end = 2.dp, start = 2.dp)
|
||||
.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
|
||||
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
|
||||
@Composable
|
||||
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
|
||||
val batteryOutlineColor = Color(0xFFBFBFBF)
|
||||
val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C)
|
||||
val batteryTextColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val batteryWidth = 40.dp
|
||||
val batteryHeight = 15.dp
|
||||
val batteryCornerRadius = 4.dp
|
||||
val tipWidth = 5.dp
|
||||
val tipHeight = batteryHeight * 0.375f
|
||||
|
||||
val animatedFillWidth by animateFloatAsState(targetValue = batteryPercentage / 100f)
|
||||
val animatedScale by animateFloatAsState(targetValue = if (charging) 1.2f else 1f)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(batteryWidth)
|
||||
.height(batteryHeight)
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(2.dp)
|
||||
.width(batteryWidth * animatedFillWidth)
|
||||
.background(batteryFillColor, RoundedCornerShape(2.dp))
|
||||
)
|
||||
if (charging) {
|
||||
Text(
|
||||
text = "\uDBC0\uDEE6",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.scale(animatedScale)
|
||||
.fillMaxSize(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(tipWidth)
|
||||
.height(tipHeight)
|
||||
.padding(start = 1.dp)
|
||||
.background(
|
||||
batteryOutlineColor,
|
||||
RoundedCornerShape(
|
||||
topStart = 0.dp,
|
||||
topEnd = 12.dp,
|
||||
bottomStart = 0.dp,
|
||||
bottomEnd = 12.dp
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "$batteryPercentage%",
|
||||
color = batteryTextColor,
|
||||
style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
BatteryIndicator(batteryPercentage = 48, charging = true)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
var conversationalAwarenessEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateConversationalAwareness(enabled: Boolean) {
|
||||
conversationalAwarenessEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
|
||||
service.setCAEnabled(enabled)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateConversationalAwareness(!conversationalAwarenessEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Conversational Awareness",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Lowers media volume and reduces background noise when you start speaking to other people.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = conversationalAwarenessEnabled,
|
||||
onCheckedChange = {
|
||||
updateConversationalAwareness(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitchPreview() {
|
||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||
var checked by remember { mutableStateOf(default) }
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
LaunchedEffect(sharedPreferences) {
|
||||
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
||||
}
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
checked = !checked
|
||||
sharedPreferences
|
||||
.edit()
|
||||
.putBoolean(snakeCasedName, checked)
|
||||
.apply()
|
||||
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, checked)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
{
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
|
||||
StyledSwitch(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
checked = it
|
||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun IndependentTogglePreview() {
|
||||
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
var loudSoundReductionEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("loud_sound_reduction", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateLoudSoundReduction(enabled: Boolean) {
|
||||
loudSoundReductionEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
|
||||
service.setLoudSoundReduction(enabled)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateLoudSoundReduction(!loudSoundReductionEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Loud Sound Reduction",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Reduces loud sounds you are exposed to.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
updateLoudSoundReduction(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LoudSoundReductionSwitchPreview() {
|
||||
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@Composable
|
||||
fun NameField(
|
||||
name: String,
|
||||
value: String,
|
||||
navController: NavController
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val cursorColor = if (isFocused) {
|
||||
if (isDarkTheme) Color.White else Color.Black
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
navController.navigate("rename")
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.background(
|
||||
animatedBackgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
textStyle = TextStyle(
|
||||
color = textColor.copy(alpha = 0.75f),
|
||||
fontSize = 16.sp,
|
||||
textAlign = TextAlign.End
|
||||
),
|
||||
onValueChange = {},
|
||||
singleLine = true,
|
||||
enabled = false,
|
||||
cursorBrush = SolidColor(cursorColor),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
},
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
innerTextField()
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "Edit name",
|
||||
tint = textColor.copy(alpha = 0.75f),
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StyledTextFieldPreview() {
|
||||
NameField(name = "Name", value = "AirPods Pro", rememberNavController())
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
|
||||
|
||||
@Composable
|
||||
fun NavigationButton(to: String, name: String, navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
|
||||
.height(55.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
navController.navigate(to)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { navController.navigate(to) },
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = if (isDarkTheme) Color.White else Color.Black
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowRight,
|
||||
contentDescription = name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NavigationButtonPreview() {
|
||||
NavigationButton("to", "Name", NavController(LocalContext.current))
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
var personalizedVolumeEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("personalized_volume", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updatePersonalizedVolume(enabled: Boolean) {
|
||||
personalizedVolumeEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
|
||||
service.setPVEnabled(enabled)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updatePersonalizedVolume(!personalizedVolumeEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Personalized Volume",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Adjusts the volume of media in response to your environment.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = personalizedVolumeEnabled,
|
||||
onCheckedChange = {
|
||||
updatePersonalizedVolume(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PersonalizedVolumeSwitchPreview() {
|
||||
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = Color(0x40888888)
|
||||
var leftBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
var rightBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
|
||||
val animationSpec = tween<Color>(durationMillis = 500)
|
||||
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
|
||||
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.background(animatedLeftBackgroundColor, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
leftBackgroundColor = dividerColor
|
||||
tryAwaitRelease()
|
||||
leftBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
navController.navigate("long_press/Left")
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate("long_press/Left")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "go",
|
||||
tint = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.5.dp,
|
||||
color = dividerColor,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.background(animatedRightBackgroundColor, RoundedCornerShape(bottomEnd = 14.dp, bottomStart = 14.dp))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
rightBackgroundColor = dividerColor
|
||||
tryAwaitRelease()
|
||||
rightBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
navController.navigate("long_press/Right")
|
||||
}
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate("long_press/Right")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = "go",
|
||||
tint = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PressAndHoldSettingsPreview() {
|
||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
var singleANCEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("single_anc", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSingleEnabled(enabled: Boolean) {
|
||||
singleANCEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
|
||||
service.setNoiseCancellationWithOnePod(enabled)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateSingleEnabled(!singleANCEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Noise Cancellation with Single AirPod",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = singleANCEnabled,
|
||||
onCheckedChange = {
|
||||
updateSingleEnabled(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SinglePodANCSwitchPreview() {
|
||||
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun StyledSwitch(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val thumbColor = Color.White
|
||||
val trackColor = if (enabled) (
|
||||
if (isDarkTheme) {
|
||||
if (checked) Color(0xFF34C759) else Color(0xFF5B5B5E)
|
||||
} else {
|
||||
if (checked) Color(0xFF34C759) else Color(0xFFD1D1D6)
|
||||
}
|
||||
) else {
|
||||
if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
}
|
||||
|
||||
|
||||
val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(51.dp)
|
||||
.height(31.dp)
|
||||
.clip(RoundedCornerShape(15.dp))
|
||||
.background(trackColor) // Dynamic track background
|
||||
.padding(horizontal = 3.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = thumbOffsetX)
|
||||
.size(27.dp)
|
||||
.clip(CircleShape)
|
||||
.background(thumbColor)
|
||||
.clickable { if (enabled) onCheckedChange(!checked) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StyledSwitchPreview() {
|
||||
StyledSwitch(checked = true, onCheckedChange = {})
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("tone_volume")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sliderValue.floatValue) {
|
||||
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "\uDBC0\uDEA1",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Slider(
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
service.setToneVolume(volume = it.toInt())
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(sliderValue.floatValue / 100)
|
||||
.height(4.dp)
|
||||
.background(activeTrackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = "\uDBC0\uDEA9",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ToneVolumeSliderPreview() {
|
||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) }
|
||||
var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) }
|
||||
var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) }
|
||||
var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) }
|
||||
var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) }
|
||||
var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Transparency Mode",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "You can customize Transparency mode for your AirPods Pro.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = transparencyModeCustomizationEnabled,
|
||||
onCheckedChange = {
|
||||
transparencyModeCustomizationEnabled = it
|
||||
},
|
||||
)
|
||||
}
|
||||
if (transparencyModeCustomizationEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SliderRow(
|
||||
label = "Amplification",
|
||||
value = amplification,
|
||||
onValueChange = {
|
||||
amplification = it
|
||||
sharedPreferences.edit().putInt("transparency_amplification", it).apply()
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SliderRow(
|
||||
label = "Balance",
|
||||
value = balance,
|
||||
onValueChange = {
|
||||
balance = it
|
||||
sharedPreferences.edit().putInt("transparency_balance", it).apply()
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SliderRow(
|
||||
label = "Tone",
|
||||
value = tone,
|
||||
onValueChange = {
|
||||
tone = it
|
||||
sharedPreferences.edit().putInt("transparency_tone", it).apply()
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SliderRow(
|
||||
label = "Ambient Noise",
|
||||
value = ambientNoise,
|
||||
onValueChange = {
|
||||
ambientNoise = it
|
||||
sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply()
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
conversationBoostEnabled = !conversationBoostEnabled
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Conversation Boost",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = conversationBoostEnabled,
|
||||
onCheckedChange = {
|
||||
conversationBoostEnabled = it
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SliderRow(
|
||||
label: String,
|
||||
value: Int,
|
||||
onValueChange: (Int) -> Unit,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = Color(0xFFFFFFFF)
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "\uDBC0\uDEA1",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Slider(
|
||||
value = value.toFloat(),
|
||||
onValueChange = {
|
||||
onValueChange(it.toInt())
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = {
|
||||
onValueChange(value)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(value.toFloat() / 100)
|
||||
.height(4.dp)
|
||||
.background(activeTrackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = "\uDBC0\uDEA9",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
var volumeControlEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("volume_control", true)
|
||||
)
|
||||
}
|
||||
fun updateVolumeControlEnabled(enabled: Boolean) {
|
||||
volumeControlEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
|
||||
service.setVolumeControl(enabled)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val isPressed = remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed.value = true
|
||||
tryAwaitRelease()
|
||||
isPressed.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateVolumeControlEnabled(!volumeControlEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Volume Control",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.",
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
StyledSwitch(
|
||||
checked = volumeControlEnabled,
|
||||
onCheckedChange = {
|
||||
updateVolumeControlEnabled(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun VolumeControlSwitchPreview() {
|
||||
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.composables.AccessibilitySettings
|
||||
import me.kavishdevar.aln.composables.AudioSettings
|
||||
import me.kavishdevar.aln.composables.BatteryView
|
||||
import me.kavishdevar.aln.composables.IndependentToggle
|
||||
import me.kavishdevar.aln.composables.NameField
|
||||
import me.kavishdevar.aln.composables.NavigationButton
|
||||
import me.kavishdevar.aln.composables.NoiseControlSettings
|
||||
import me.kavishdevar.aln.composables.PressAndHoldSettings
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val nameChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "name") {
|
||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
Scaffold(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(
|
||||
0xFF000000
|
||||
) else Color(
|
||||
0xFFF2F2F7
|
||||
),
|
||||
topBar = {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val mDensity = remember { mutableFloatStateOf(1f) }
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = deviceName.text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeChild(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = {
|
||||
alpha =
|
||||
if (verticalScrollState.value > 55.dp.value * mDensity.floatValue) 1f else 0f
|
||||
}
|
||||
)
|
||||
.drawBehind {
|
||||
mDensity.floatValue = density
|
||||
val strokeWidth = 0.7.dp.value * density
|
||||
val y = size.height - strokeWidth / 2
|
||||
if (verticalScrollState.value > 55.dp.value * density) {
|
||||
drawLine(
|
||||
if (darkMode) Color.DarkGray else Color.LightGray,
|
||||
Offset(0f, y),
|
||||
Offset(size.width, y),
|
||||
strokeWidth
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate("app_settings")
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (isConnected == true || isRemotelyConnected == true) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.haze(hazeState)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(
|
||||
state = verticalScrollState,
|
||||
enabled = true,
|
||||
)
|
||||
) {
|
||||
Spacer(Modifier.height(75.dp))
|
||||
LaunchedEffect(service) {
|
||||
service.let {
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
|
||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
||||
})
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
|
||||
putExtra("data", it.getANC())
|
||||
})
|
||||
}
|
||||
}
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
BatteryView(service = service)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
NameField(
|
||||
name = stringResource(R.string.name),
|
||||
value = deviceName.text,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
NoiseControlSettings(service = service)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PressAndHoldSettings(navController = navController)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AudioSettings(service = service, sharedPreferences = sharedPreferences)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Automatic Ear Detection",
|
||||
service = service,
|
||||
functionName = "setEarDetection",
|
||||
sharedPreferences = sharedPreferences,
|
||||
true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Off Listening Mode",
|
||||
service = service,
|
||||
functionName = "setOffListeningMode",
|
||||
sharedPreferences = sharedPreferences,
|
||||
false
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NavigationButton("debug", "Debug", navController)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
.verticalScroll(
|
||||
state = verticalScrollState,
|
||||
enabled = true,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "AirPods not connected",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Please connect your AirPods to access settings.",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AirPodsSettingsScreenPreview() {
|
||||
Column (
|
||||
modifier = Modifier.height(2000.dp)
|
||||
) {
|
||||
ALNTheme (
|
||||
darkTheme = true
|
||||
) {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.composables.IndependentToggle
|
||||
import me.kavishdevar.aln.composables.StyledSwitch
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(navController: NavController) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.app_settings),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
text = name.value,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
IndependentToggle("Show phone battery in widget", ServiceManager.getService()!!, "setPhoneBatteryInWidget", sharedPreferences)
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(275.sp.value.dp)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("conversational_awareness_volume")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 0).toFloat()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sliderValue.floatValue) {
|
||||
sharedPreferences.edit().putInt("conversational_awareness_volume", sliderValue.floatValue.toInt()).apply()
|
||||
}
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness_customization),
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
|
||||
var conversationalAwarenessPauseMusicEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("conversational_awareness_pause_music", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
||||
conversationalAwarenessPauseMusicEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply()
|
||||
}
|
||||
|
||||
var relativeConversationalAwarenessVolumeEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("relative_conversational_awareness_volume", true)
|
||||
)
|
||||
}
|
||||
|
||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
||||
relativeConversationalAwarenessVolumeEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(85.sp.value.dp)
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateConversationalAwarenessPauseMusic(!conversationalAwarenessPauseMusicEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness_pause_music),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = {
|
||||
updateConversationalAwarenessPauseMusic(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(85.sp.value.dp)
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateRelativeConversationalAwarenessVolume(!relativeConversationalAwarenessVolumeEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = {
|
||||
updateRelativeConversationalAwarenessVolume(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
Slider(
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
},
|
||||
valueRange = 10f..85f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor,
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(((sliderValue.floatValue - 10) * 100) /7500)
|
||||
.height(4.dp)
|
||||
.background(if (conversationalAwarenessPauseMusicEnabled) trackColor else activeTrackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "10%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = "85%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = labelTextColor
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppSettingsScreenPreview() {
|
||||
AppSettingsScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun DebugScreen(navController: NavController) {
|
||||
val hazeState = remember { HazeState() }
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
|
||||
val packetLogsFlow = remember { MutableStateFlow(emptySet<String>()) }
|
||||
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
ServiceManager.getService()?.packetLogsFlow?.collect { packetLogsFlow.value = it }
|
||||
}
|
||||
val packetLogs = packetLogsFlow.collectAsState(setOf()).value
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Debug") },
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
sharedPreferences.getString("name", "AirPods")!!,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeChild(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = {
|
||||
alpha = if (scrollOffset > 0) {
|
||||
1f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.haze(hazeState)
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = {
|
||||
items(packetLogs.size) { index ->
|
||||
val message = packetLogs.elementAt(index)
|
||||
val isSent = message.startsWith("Sent")
|
||||
val isExpanded = expandedItems.value.contains(index)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
.clickable {
|
||||
expandedItems.value = if (isExpanded) {
|
||||
expandedItems.value - index
|
||||
} else {
|
||||
expandedItems.value + index
|
||||
}
|
||||
},
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (isSent) Color.Green else Color.Red,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text =
|
||||
if (isSent) message.substring(5).take(60) + (if (message.substring(5).length > 60) "..." else "")
|
||||
else message.substring(9).take(60) + (if (message.substring(9).length > 60) "..." else ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message.substring(if (isSent) 5 else 9),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val packet = remember { mutableStateOf(TextFieldValue("")) }
|
||||
TextField(
|
||||
value = packet.value,
|
||||
onValueChange = { packet.value = it },
|
||||
label = { Text("Packet") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
airPodsService?.value?.sendPacket(packet.value.text)
|
||||
packet.value = TextFieldValue("")
|
||||
}
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
|
||||
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
|
||||
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
|
||||
@Composable()
|
||||
fun RightDivider() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.5.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 72.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(navController: NavController, name: String) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) }
|
||||
val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) }
|
||||
val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) }
|
||||
val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) }
|
||||
Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}")
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
name,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
sharedPreferences.getString("name", "AirPods")!!,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues = paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "NOISE CONTROL",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
||||
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
|
||||
if (offListeningMode) RightDivider()
|
||||
LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode)
|
||||
RightDivider()
|
||||
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
|
||||
RightDivider()
|
||||
LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true)
|
||||
}
|
||||
Text(
|
||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
||||
val sharedPreferences =
|
||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val textColor = if (darkMode) Color.White else Color.Black
|
||||
val desc = when (name) {
|
||||
"Off" -> "Turns off noise management"
|
||||
"Noise Cancellation" -> "Blocks out external sounds"
|
||||
"Transparency" -> "Lets in external sounds"
|
||||
"Adaptive" -> "Dynamically adjust external noise"
|
||||
else -> ""
|
||||
}
|
||||
fun valueChanged(value: Boolean = !checked.value) {
|
||||
val originalLongPressArray = booleanArrayOf(
|
||||
sharedPreferences.getBoolean("long_press_off", false),
|
||||
sharedPreferences.getBoolean("long_press_nc", false),
|
||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
||||
)
|
||||
if (!value && originalLongPressArray.count { it } <= 2) {
|
||||
return
|
||||
}
|
||||
checked.value = value
|
||||
with(sharedPreferences.edit()) {
|
||||
putBoolean(id, checked.value)
|
||||
apply()
|
||||
}
|
||||
val newLongPressArray = booleanArrayOf(
|
||||
sharedPreferences.getBoolean("long_press_off", false),
|
||||
sharedPreferences.getBoolean("long_press_nc", false),
|
||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
||||
)
|
||||
ServiceManager.getService()
|
||||
?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode)
|
||||
}
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (!enabled) {
|
||||
valueChanged(false)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(72.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = { valueChanged() }
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(
|
||||
bitmap = ImageBitmap.imageResource(resourceId),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = 8.dp)
|
||||
)
|
||||
{
|
||||
Text(
|
||||
name,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
Text (
|
||||
desc,
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
checked = checked.value,
|
||||
onCheckedChange = { valueChanged() },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.scale(1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
import me.kavishdevar.aln.utils.NoiseControlMode
|
||||
|
||||
class AirPodsQSService: TileService() {
|
||||
private val ancModes = listOf(NoiseControlMode.NOISE_CANCELLATION.name, NoiseControlMode.TRANSPARENCY.name, NoiseControlMode.ADAPTIVE.name)
|
||||
private var currentModeIndex = 2
|
||||
private lateinit var ancStatusReceiver: BroadcastReceiver
|
||||
private lateinit var availabilityReceiver: BroadcastReceiver
|
||||
|
||||
@SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
currentModeIndex = (ServiceManager.getService()?.getANC()?.minus(1)) ?: -1
|
||||
if (currentModeIndex == -1) {
|
||||
currentModeIndex = 2
|
||||
}
|
||||
|
||||
if (ServiceManager.getService() == null) {
|
||||
qsTile.state = Tile.STATE_UNAVAILABLE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||
qsTile.state = Tile.STATE_ACTIVE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
else {
|
||||
qsTile.state = Tile.STATE_UNAVAILABLE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
|
||||
ancStatusReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val ancStatus = intent.getIntExtra("data", 4)
|
||||
currentModeIndex = if (ancStatus == 2) 0 else if (ancStatus == 3) 1 else if (ancStatus == 4) 2 else 2
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
availabilityReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.Companion.AIRPODS_CONNECTED) {
|
||||
qsTile.state = Tile.STATE_ACTIVE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
else if (intent.action == AirPodsNotifications.Companion.AIRPODS_DISCONNECTED) {
|
||||
qsTile.state = Tile.STATE_UNAVAILABLE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(
|
||||
ancStatusReceiver,
|
||||
IntentFilter(AirPodsNotifications.Companion.ANC_DATA), RECEIVER_EXPORTED
|
||||
)
|
||||
} else {
|
||||
registerReceiver(
|
||||
ancStatusReceiver,
|
||||
IntentFilter(AirPodsNotifications.Companion.ANC_DATA)
|
||||
)
|
||||
}
|
||||
qsTile.state = if (ServiceManager.getService()?.isConnectedLocally == true) Tile.STATE_ACTIVE else Tile.STATE_UNAVAILABLE
|
||||
val ancIndex = ServiceManager.getService()?.getANC()
|
||||
currentModeIndex = if (ancIndex != null) { if (ancIndex == 2) 0 else if (ancIndex == 3) 1 else if (ancIndex == 4) 2 else 2 } else 0
|
||||
updateTile()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
super.onStopListening()
|
||||
try {
|
||||
unregisterReceiver(ancStatusReceiver)
|
||||
}
|
||||
catch (
|
||||
_: IllegalArgumentException
|
||||
)
|
||||
{
|
||||
Log.e("QuickSettingTileService", "Receiver not registered")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(availabilityReceiver)
|
||||
}
|
||||
catch (
|
||||
_: IllegalArgumentException
|
||||
)
|
||||
{
|
||||
Log.e("QuickSettingTileService", "Receiver not registered")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
Log.d("QuickSettingTileService", "ANC tile clicked")
|
||||
currentModeIndex = (currentModeIndex + 1) % ancModes.size
|
||||
Log.d("QuickSettingTileService", "New mode index: $currentModeIndex, would be set to ${currentModeIndex + 1}")
|
||||
switchAncMode()
|
||||
}
|
||||
|
||||
private fun updateTile() {
|
||||
val currentMode = ancModes[currentModeIndex % ancModes.size]
|
||||
qsTile.label = currentMode.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() }
|
||||
qsTile.state = Tile.STATE_ACTIVE
|
||||
qsTile.updateTile()
|
||||
}
|
||||
|
||||
private fun switchAncMode() {
|
||||
val airPodsService = ServiceManager.getService()
|
||||
Log.d("QuickSettingTileService", "Setting ANC mode to ${currentModeIndex + 2}")
|
||||
airPodsService?.setANCMode(currentModeIndex + 2)
|
||||
Log.d("QuickSettingTileService", "ANC mode set to ${currentModeIndex + 2}")
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PixelFormat
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log.e
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AnticipateOvershootInterpolator
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import androidx.core.content.ContextCompat.getString
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
|
||||
class IslandWindow(context: Context) {
|
||||
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@SuppressLint("InflateParams")
|
||||
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
||||
private var isClosing = false
|
||||
|
||||
val isVisible: Boolean
|
||||
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) {
|
||||
if (ServiceManager.getService()?.islandOpen == true) return
|
||||
else ServiceManager.getService()?.islandOpen = true
|
||||
|
||||
val displayMetrics = Resources.getSystem().displayMetrics
|
||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
width,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
|
||||
islandView.visibility = View.VISIBLE
|
||||
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
|
||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||
|
||||
islandView.setOnClickListener {
|
||||
ServiceManager.getService()?.startMainActivity()
|
||||
close()
|
||||
}
|
||||
|
||||
if (takingOver) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
|
||||
} else if (CrossDevice.isAvailable) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
|
||||
} else {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
|
||||
}
|
||||
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
batteryProgressBar.progress = batteryPercentage
|
||||
batteryProgressBar.isIndeterminate = false
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}")
|
||||
videoView.setVideoURI(videoUri)
|
||||
videoView.setOnPreparedListener { mediaPlayer ->
|
||||
mediaPlayer.isLooping = true
|
||||
videoView.start()
|
||||
}
|
||||
|
||||
windowManager.addView(islandView, params)
|
||||
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
start()
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
close()
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
videoView.stopPlayback()
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
|
||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
islandView.visibility = View.GONE
|
||||
try {
|
||||
windowManager.removeView(islandView)
|
||||
} catch (e: Exception) {
|
||||
e("IslandWindow", "Error removing view: $e")
|
||||
}
|
||||
isClosing = false
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
|
||||
object MediaController {
|
||||
private var initialVolume: Int? = null
|
||||
private lateinit var audioManager: AudioManager
|
||||
var iPausedTheMedia = false
|
||||
var userPlayedTheMedia = false
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
var pausedForCrossDevice = false
|
||||
|
||||
private var relativeVolume: Boolean = false
|
||||
private var conversationalAwarenessVolume: Int = 1/12
|
||||
private var conversationalAwarenessPauseMusic: Boolean = false
|
||||
|
||||
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
||||
if (this::audioManager.isInitialized) {
|
||||
return
|
||||
}
|
||||
this.audioManager = audioManager
|
||||
this.sharedPreferences = sharedPreferences
|
||||
Log.d("MediaController", "Initializing MediaController")
|
||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
"relative_conversational_awareness_volume" -> {
|
||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||
}
|
||||
"conversational_awareness_volume" -> {
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
||||
}
|
||||
"conversational_awareness_pause_music" -> {
|
||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioManager.registerAudioPlaybackCallback(cb, null)
|
||||
}
|
||||
|
||||
val cb = object : AudioManager.AudioPlaybackCallback() {
|
||||
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||
super.onPlaybackConfigChanged(configs)
|
||||
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
|
||||
if (configs != null && !iPausedTheMedia) {
|
||||
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't `play` until the ear detection pauses it.")
|
||||
handler.postDelayed({
|
||||
iPausedTheMedia = !audioManager.isMusicActive
|
||||
userPlayedTheMedia = audioManager.isMusicActive
|
||||
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
||||
}
|
||||
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
|
||||
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
|
||||
if (ServiceManager.getService()?.isConnectedLocally == false) {
|
||||
sendPause(true)
|
||||
pausedForCrossDevice = true
|
||||
}
|
||||
ServiceManager.getService()?.takeOver()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPause(force: Boolean = false) {
|
||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
|
||||
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
|
||||
iPausedTheMedia = true
|
||||
userPlayedTheMedia = false
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPlay() {
|
||||
Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia")
|
||||
if (iPausedTheMedia) {
|
||||
Log.d("MediaController", "Sending play and setting userPlayedTheMedia to false")
|
||||
userPlayedTheMedia = false
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSpeaking() {
|
||||
Log.d("MediaController", "Starting speaking")
|
||||
if (initialVolume == null) {
|
||||
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
Log.d("MediaController", "Initial Volume Set: $initialVolume")
|
||||
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
|
||||
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
|
||||
if (conversationalAwarenessPauseMusic) {
|
||||
sendPause(force = true)
|
||||
}
|
||||
}
|
||||
Log.d("MediaController", "Initial Volume: $initialVolume")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopSpeaking() {
|
||||
Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume")
|
||||
if (initialVolume != null) {
|
||||
smoothVolumeTransition(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC), initialVolume!!)
|
||||
if (conversationalAwarenessPauseMusic) {
|
||||
sendPlay()
|
||||
}
|
||||
initialVolume = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
|
||||
val step = if (fromVolume < toVolume) 1 else -1
|
||||
val delay = 50L
|
||||
var currentVolume = fromVolume
|
||||
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (currentVolume != toVolume) {
|
||||
currentVolume += step
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0)
|
||||
handler.postDelayed(this, delay)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
|
||||
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
|
||||
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
|
||||
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
|
||||
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
|
||||
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00));
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
const val LEFT = 4
|
||||
const val RIGHT = 2
|
||||
const val CASE = 8
|
||||
}
|
||||
|
||||
object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
|
||||
fun getComponentName(): String? {
|
||||
return when (component) {
|
||||
BatteryComponent.LEFT -> "LEFT"
|
||||
BatteryComponent.RIGHT -> "RIGHT"
|
||||
BatteryComponent.CASE -> "CASE"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusName(): String? {
|
||||
return when (status) {
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class NoiseControlMode {
|
||||
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
|
||||
}
|
||||
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.aln.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA"
|
||||
const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA"
|
||||
const val CA_DATA = "me.kavishdevar.aln.CA_DATA"
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.aln.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.aln.DISCONNECT_RECEIVERS"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = listOf(data[6], data[7])
|
||||
}
|
||||
|
||||
fun isEarDetectionData(data: ByteArray): Boolean {
|
||||
if (data.size != 8) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ANC {
|
||||
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
|
||||
|
||||
var status: Int = 1
|
||||
private set
|
||||
|
||||
fun isANCData(data: ByteArray): Boolean {
|
||||
if (data.size != 11) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
if (data.size != 11) {
|
||||
return
|
||||
}
|
||||
status = data[7].toInt()
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
4 -> "ADAPTIVE"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BatteryNotification {
|
||||
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
|
||||
|
||||
fun isBatteryData(data: ByteArray): Boolean {
|
||||
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
|
||||
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
if (data.size != 22) {
|
||||
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
|
||||
return false
|
||||
}
|
||||
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
|
||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||
}
|
||||
|
||||
fun setBattery(data: ByteArray) {
|
||||
if (data.size != 22) {
|
||||
return
|
||||
}
|
||||
first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
Battery(first.component, first.level, data[10].toInt())
|
||||
} else {
|
||||
Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
|
||||
}
|
||||
second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
Battery(second.component, second.level, data[15].toInt())
|
||||
} else {
|
||||
Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
|
||||
}
|
||||
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
|
||||
Battery(case.component, case.level, data[20].toInt())
|
||||
} else {
|
||||
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
|
||||
}
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
|
||||
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
|
||||
enum class NoiseCancellation(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x01)),
|
||||
ON(byteArrayOf(0x02)),
|
||||
TRANSPARENCY(byteArrayOf(0x03)),
|
||||
ADAPTIVE(byteArrayOf(0x04));
|
||||
}
|
||||
|
||||
enum class ConversationAwareness(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x02)),
|
||||
ON(byteArrayOf(0x01));
|
||||
}
|
||||
}
|
||||
|
||||
enum class LongPressPackets(val value: ByteArray) {
|
||||
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.aln.R
|
||||
|
||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||
class PopupWindow(context: Context) {
|
||||
private val mView: View
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
gravity = Gravity.BOTTOM
|
||||
dimAmount = 0.3f
|
||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
||||
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
|
||||
}
|
||||
|
||||
private val mWindowManager: WindowManager
|
||||
|
||||
init {
|
||||
val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||
mView = layoutInflater.inflate(R.layout.popup_window, null)
|
||||
mParams.x = 0
|
||||
mParams.y = 0
|
||||
|
||||
mParams.gravity = Gravity.BOTTOM
|
||||
mView.setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
|
||||
ll.setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
|
||||
mView.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
val touchY = event.rawY
|
||||
val popupTop = mView.top
|
||||
if (touchY < popupTop) {
|
||||
close()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi", "SetTextI18n")
|
||||
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||
try {
|
||||
if (mView.windowToken == null) {
|
||||
if (mView.parent == null) {
|
||||
mWindowManager.addView(mView, mParams)
|
||||
mView.findViewById<TextView>(R.id.name).text = name
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
|
||||
vid.setVideoPath("android.resource://me.kavishdevar.aln/" + R.raw.connected)
|
||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||
vid.start()
|
||||
vid.setOnCompletionListener {
|
||||
vid.start()
|
||||
}
|
||||
|
||||
val batteryStatus = batteryNotification.getBattery()
|
||||
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
||||
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
||||
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
|
||||
|
||||
batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let {
|
||||
"\uDBC3\uDC8E ${it.level}%"
|
||||
} ?: ""
|
||||
batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let {
|
||||
"\uDBC3\uDC8D ${it.level}%"
|
||||
} ?: ""
|
||||
batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let {
|
||||
"\uDBC3\uDE6C ${it.level}%"
|
||||
} ?: ""
|
||||
|
||||
val displayMetrics = mView.context.resources.displayMetrics
|
||||
val screenHeight = displayMetrics.heightPixels
|
||||
|
||||
mView.translationY = screenHeight.toFloat()
|
||||
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
|
||||
duration = 500
|
||||
interpolator = DecelerateInterpolator()
|
||||
start()
|
||||
}
|
||||
|
||||
CoroutineScope(MainScope().coroutineContext).launch {
|
||||
delay(12000)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PopupService", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
|
||||
duration = 500
|
||||
interpolator = AccelerateInterpolator()
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
try {
|
||||
mWindowManager.removeView(mView)
|
||||
} catch (e: Exception) {
|
||||
Log.d("PopupService", e.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("PopupService", e.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,42 @@
|
||||
/*
|
||||
* 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 Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
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
|
||||
@@ -37,23 +44,27 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
import me.kavishdevar.aln.utils.Battery
|
||||
import me.kavishdevar.aln.utils.BatteryComponent
|
||||
import me.kavishdevar.aln.utils.BatteryStatus
|
||||
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) {
|
||||
@@ -93,16 +104,44 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
previousBatteryStatus.value = batteryStatus.value
|
||||
batteryStatus.value = service.getBattery()
|
||||
|
||||
if (preview) {
|
||||
batteryStatus.value = listOf<Battery>(
|
||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING),
|
||||
Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING)
|
||||
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
|
||||
@@ -110,47 +149,52 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image (
|
||||
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
|
||||
bitmap = ImageBitmap.imageResource(budsRes),
|
||||
contentDescription = stringResource(R.string.buds),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scale(0.80f)
|
||||
.padding(8.dp)
|
||||
)
|
||||
if (
|
||||
leftCharging == rightCharging &&
|
||||
(leftLevel - rightLevel) in -3..3
|
||||
)
|
||||
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)
|
||||
BatteryIndicator(
|
||||
leftLevel.coerceAtMost(rightLevel),
|
||||
leftCharging,
|
||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
||||
)
|
||||
singleDisplayed.value = true
|
||||
}
|
||||
else {
|
||||
singleDisplayed.value = false
|
||||
Row (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
// if (left?.status != BatteryStatus.DISCONNECTED) {
|
||||
if (left?.level != null) {
|
||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
left.level,
|
||||
left.status == BatteryStatus.CHARGING
|
||||
leftLevel,
|
||||
leftCharging,
|
||||
"\uDBC6\uDCE5",
|
||||
previousCharging = prevLeftCharging
|
||||
)
|
||||
}
|
||||
// }
|
||||
// if (left?.status != BatteryStatus.DISCONNECTED && right?.status != BatteryStatus.DISCONNECTED) {
|
||||
if (left?.level != null && right?.level != null)
|
||||
if (leftLevel > 0 && rightLevel > 0)
|
||||
{
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
// }
|
||||
// if (right?.status != BatteryStatus.DISCONNECTED) {
|
||||
if (right?.level != null)
|
||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
||||
{
|
||||
BatteryIndicator(
|
||||
right.level,
|
||||
right.status == BatteryStatus.CHARGING
|
||||
rightLevel,
|
||||
rightCharging,
|
||||
"\uDBC6\uDCE8",
|
||||
previousCharging = prevRightCharging
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,26 +204,32 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
||||
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
|
||||
bitmap = ImageBitmap.imageResource(caseRes),
|
||||
contentDescription = stringResource(R.string.case_alt),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scale(1.25f)
|
||||
.padding(8.dp)
|
||||
)
|
||||
// if (case?.status != BatteryStatus.DISCONNECTED) {
|
||||
if (case?.level != null) {
|
||||
BatteryIndicator(case.level, case.status == BatteryStatus.CHARGING)
|
||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
caseLevel,
|
||||
caseCharging,
|
||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
||||
previousCharging = prevCaseCharging
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryViewPreview() {
|
||||
BatteryView(AirPodsService(), preview = true)
|
||||
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))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.composables
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -35,7 +35,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun NoiseControlButton(
|
||||
@@ -1,29 +1,30 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.aln.composables
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -72,34 +72,36 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
import me.kavishdevar.aln.utils.NoiseControlMode
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(service: AirPodsService) {
|
||||
fun NoiseControlSettings(
|
||||
service: AirPodsService,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
|
||||
|
||||
val preferenceChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "off_listening_mode") {
|
||||
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||
|
||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
offListeningModeListener
|
||||
)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
@@ -113,12 +115,20 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
val d3a = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||
if (!received && !offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
noiseControlMode.value = NoiseControlMode.ADAPTIVE
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
noiseControlMode.value = mode
|
||||
mode
|
||||
}
|
||||
if (!received) service.setANCMode(mode.ordinal + 1)
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) {
|
||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||
}
|
||||
|
||||
when (noiseControlMode.value) {
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
@@ -169,16 +179,21 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
} else {
|
||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
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()
|
||||
@@ -230,7 +245,7 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp))
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -312,9 +327,10 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> null
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
newMode?.let { onModeSelected(it) }
|
||||
// Call onModeSelected which now handles service call but not callback
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -322,7 +338,7 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(12.dp))
|
||||
.background(selectedBackground, RoundedCornerShape(26.dp))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -388,7 +404,6 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
@@ -396,7 +411,6 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -404,21 +418,18 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
@@ -426,8 +437,8 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview()
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -16,12 +16,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.receivers
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
|
||||
class BootReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
@@ -16,37 +16,31 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -58,17 +52,23 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
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)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun RenameScreen(navController: NavController) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
@@ -83,54 +83,18 @@ fun RenameScreen(navController: NavController) {
|
||||
name.value = name.value.copy(selection = TextRange(name.value.text.length))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.name),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
text = name.value.text,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column (
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.name),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues = paddingValues)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.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
|
||||
@@ -139,10 +103,10 @@ fun RenameScreen(navController: NavController) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp)
|
||||
.height(58.dp)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
@@ -150,12 +114,13 @@ fun RenameScreen(navController: NavController) {
|
||||
value = name.value,
|
||||
onValueChange = {
|
||||
name.value = it
|
||||
sharedPreferences.edit().putString("name", it.text).apply()
|
||||
sharedPreferences.edit {putString("name", it.text)}
|
||||
ServiceManager.getService()?.setName(it.text)
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(cursorColor),
|
||||
@@ -172,14 +137,15 @@ fun RenameScreen(navController: NavController) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
name.value = TextFieldValue("")
|
||||
sharedPreferences.edit().putString("name", "").apply()
|
||||
ServiceManager.getService()?.setName("")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = "Clear",
|
||||
tint = if (isDarkTheme) Color.White else Color.Black
|
||||
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)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,4 +164,4 @@ fun RenameScreen(navController: NavController) {
|
||||
@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 disconnect 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user