Compare commits
219 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -11,6 +11,10 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
custom_notes:
|
||||
description: 'Custom updates to add to What''s Changed section'
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
@@ -43,7 +47,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Export APK_NAME for later use
|
||||
run: echo "APK_NAME=ALN-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
- name: Rename .apk file
|
||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
||||
- name: Decode keystore file
|
||||
@@ -63,12 +67,32 @@ jobs:
|
||||
run: |
|
||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
||||
echo "::set-output name=commits::${COMMITS}"
|
||||
- name: Prepare release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
# Create a temporary file for release notes
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
# Process custom notes if they exist
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
||||
|
||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
||||
# Already formatted, use as is
|
||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
else
|
||||
# Add bullet point formatting
|
||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
- name: Zip root-module directory
|
||||
run: zip -r -0 ../btl2capfix.zip * --verbose
|
||||
working-directory: root-module
|
||||
run: sh ./build-magisk-module.sh
|
||||
- name: Delete release if exist then create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" -n "${{ steps.get-commits.outputs.commits }}" --generate-notes
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
||||
36
.github/workflows/ci-linux.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build LibrePods Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake ninja-build \
|
||||
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
|
||||
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
|
||||
libxkbcommon-dev
|
||||
|
||||
- name: Build project
|
||||
working-directory: linux
|
||||
run: |
|
||||
mkdir build
|
||||
cd build
|
||||
cmake .. -G Ninja
|
||||
ninja
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: librepods-linux
|
||||
path: linux/build/librepods
|
||||
469
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -348,32 +398,39 @@ The packets sent (based on the previous states) are as follows:
|
||||
|
||||
> *i do hate apple for not hardcoding these, like there are literally only 4^2 - ${\binom{4}{1}}$ - $\binom{4}{2}$*
|
||||
|
||||
# Miscellaneous/Unknown
|
||||
# Head Tracking
|
||||
|
||||
## Request something (Probably Head Positions)
|
||||
## Start Tracking
|
||||
|
||||
This packet initiates head tracking. When sent, the AirPods begin streaming head tracking data (e.g. orientation and acceleration) for live plotting and analysis.
|
||||
|
||||
```plaintext
|
||||
<!-- 04 00 04 00 17 00 00 00 10 00 11 00 08 7C 10 02 42 0B 08 4E 10 02 1A 05 01 40 9C 00 00 -->
|
||||
OR
|
||||
04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00
|
||||
```
|
||||
|
||||
Example packet
|
||||
## Stop Tracking
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 43 00 08 ec 07 10 01 1a 3c 0e 00 01 90 95 5d af 86 19 00 00 03 04 43 94 04 9e 6b 01 00 00 00 d5 a2 06 13 eb 13 03 00 f0 ff 01 00 67 83 67 83 67 83 fe ff fd ff 07 00 b3 01 9c 03 65 00 48 74 2c 37 fd 1e 00 00
|
||||
```
|
||||
|
||||
## Stop whatever was requested
|
||||
This packet stops the head tracking data stream.
|
||||
|
||||
```plaintext
|
||||
04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00
|
||||
```
|
||||
## Received Head Tracking Sensor Data
|
||||
|
||||
Once tracking is active, the AirPods stream sensor packets with the following common structure:
|
||||
|
||||
| Field | Offset | Length (bytes) |
|
||||
|--------------------------|--------|----------------|
|
||||
| orientation 1 | 43 | 2 |
|
||||
| orientation 2 | 45 | 2 |
|
||||
| orientation 3 | 47 | 2 |
|
||||
| Horizontal Acceleration | 51 | 2 |
|
||||
| Vertical Acceleration | 53 | 2 |
|
||||
|
||||
# LICENSE
|
||||
|
||||
AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
Copyright (C) 2024 Kavish Devar
|
||||
LibrePods - AirPods liberated from 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 +442,4 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
github: kavishdevar
|
||||
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).
|
||||
156
README.md
@@ -1,85 +1,143 @@
|
||||
# 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, 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 |
|
||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
||||
|
||||
> Can I use aln without root?
|
||||
Most features should work with any AirPods. Currently, testing is only performed with AirPods Pro 2.
|
||||
|
||||
**No, it's not possible to use aln without root.** You will have to root your device if you want to use aln, and there is no way around it. **No exceptions.**
|
||||
## Key Features
|
||||
|
||||
### Screenshots
|
||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||
- **Battery Status**: Accurate battery levels
|
||||
- **Head Gestures**: Answer calls just by nodding your head
|
||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
||||
- **Other customizations**:
|
||||
- Rename your AirPods
|
||||
- Customize long-press actions
|
||||
- Few accessibility features
|
||||
- And more!
|
||||
|
||||
Check out the new animations and transitions in the app!
|
||||
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
|
||||
|
||||
https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30
|
||||
## Platform Support
|
||||
|
||||
### Linux
|
||||
|
||||
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
|
||||
|
||||
- Battery monitoring
|
||||
- Automatic Ear detection
|
||||
- Conversational Awareness
|
||||
- Switching Noise Control modes
|
||||
- Device renaming
|
||||
|
||||
> [!NOTE]
|
||||
> Work in progress, but core functionality is stable and usable.
|
||||
|
||||
For installation and detailed info, see the [Linux README](/linux/README.md).
|
||||
|
||||
### Android
|
||||
|
||||
#### Screenshots
|
||||
|
||||
| | | |
|
||||
|-------------------|-------------------|-------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  | |
|
||||
|
||||
### Installation
|
||||
|
||||
Currently, there's a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238) that prevents the app from working (upvote the issue - click the '+1' icon on the top right corner of IssueTracker).
|
||||
#### Root Requirement
|
||||
|
||||
> [!CAUTION]
|
||||
> Until Google merges the fix **you will only be able to use aln if you are rooted**. There are **no exceptions**, don't ask about it.
|
||||
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
|
||||
>
|
||||
> There are **no exceptions** to the root requirement until Google merges the fix.
|
||||
|
||||
In order to use aln you will have to install the module using your favorite root manager in OverlayFS mode (KernelSU, Apatch, or Magisk). If you don't know what this means, no support is provided: you will have to search by yourself on Google or ask in some Android rooting communities on Telegram.
|
||||
#### Installation Methods
|
||||
|
||||
The module to install is available in the releases section under the name `btl2capfix.zip`.
|
||||
##### Method 1: Xposed Module (Recommended)
|
||||
This method is less intrusive and should be tried first:
|
||||
|
||||
### Android – features
|
||||
1. Install LSPosed, or another Xposed provider on your rooted device
|
||||
2. Download the LibrePods app from the releases section, and install it.
|
||||
3. Enable the Xposed module for the bluetooth app in your Xposed manager
|
||||
4. Follow the instructions in the app to set up the module.
|
||||
5. Open the app and connect your AirPods
|
||||
|
||||
#### Renaming the Airpods
|
||||
When you rename the Airpods using the app, you'll need to re-pair it with your phone. Currently, user-level apps cannot directly rename a Bluetooth device. After re-pairing, your phone will display the updated name!
|
||||
##### Method 2: Root Module (Backup Option)
|
||||
If the Xposed method doesn't work for you:
|
||||
|
||||
#### Noise Control Modes
|
||||
1. Download the `btl2capfix.zip` module from the releases section
|
||||
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
|
||||
3. Reboot your device
|
||||
4. Connect your AirPods
|
||||
|
||||
- Active Noise Cancellation (ANC): Blocks external sounds using microphones and advanced algorithms for an immersive audio experience; ideal for noisy environments.
|
||||
- Transparency Mode: Allows external sounds to blend with audio for situational awareness; best for environments where you need to stay alert.
|
||||
- Off Mode: Disables noise control for a natural listening experience, conserving battery in quiet settings.
|
||||
- Adaptive Transparency: Dynamically reduces sudden loud noises while maintaining environmental awareness, adjusting seamlessly to fluctuating noise levels.
|
||||
##### Method 3: Patching it yourself
|
||||
If you prefer to patch the Bluetooth stack yourself, follow these steps:
|
||||
|
||||
1. Look for the library in use by running `lsof | grep libbluetooth`
|
||||
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
|
||||
3. Find the `l2c_fcr_chk_chan_modes` function in the library
|
||||
4. Patch the function to always return `1` (true)
|
||||
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
|
||||
6. Reboot your device
|
||||
|
||||
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
|
||||
|
||||
#### A few notes
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
|
||||
|
||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
|
||||
## Development Resources
|
||||
|
||||
For developers interested in the protocol details, check out the [AAP Definitions](/AAP%20Definitions.md) documentation.
|
||||
|
||||
## CrossDevice Stuff
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
|
||||
> This feature is still in early development and might not work as expected. No support is provided for this feature yet.
|
||||
|
||||
#### Conversational Awareness
|
||||
### Features in Development
|
||||
|
||||
Automatically lowers audio volume and enhances voices when you start speaking, making it easier to engage in conversations without removing your AirPods.
|
||||
- **Battery Status Sync**: Get battery status on any device when you connect your AirPods to one of them
|
||||
- **Cross-device Controls**: Control your AirPods from either device when connected to one
|
||||
- **Automatic Device Switching**: Seamlessly switch between Linux and Android devices based on active audio sources
|
||||
|
||||
#### Automatic Ear Detection
|
||||
Check out the demo below:
|
||||
|
||||
Recognizes when the AirPods are in your ears to automatically play or pause audio and adjust functionality accordingly.
|
||||
https://github.com/user-attachments/assets/d08f8a51-cd52-458b-8e55-9b44f4d5f3ab
|
||||
|
||||
## Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md)
|
||||
## Star History
|
||||
|
||||
[](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 +150,5 @@ GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
|
||||
@@ -6,17 +6,15 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "me.kavishdevar.aln"
|
||||
namespace = "me.kavishdevar.librepods"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.aln"
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 28
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "0.0.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
versionCode = 7
|
||||
versionName = "0.1.0-rc.4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -39,6 +37,12 @@ android {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -57,11 +61,6 @@ dependencies {
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
}
|
||||
|
||||
BIN
android/app/libs/libxposed-api-100.aar
Normal file
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "me.kavishdevar.aln",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 28
|
||||
}
|
||||
@@ -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,22 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BATTERY_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.UPDATE_DEVICE_STATS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
@@ -24,6 +25,16 @@
|
||||
tools:ignore="UnusedAttribute" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -34,7 +45,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ALN"
|
||||
android:theme="@style/Theme.LibrePods"
|
||||
android:description="@string/app_description"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:targetApi="31">
|
||||
<receiver
|
||||
@@ -64,7 +76,7 @@
|
||||
android:name=".CustomDevice"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_custom_device"
|
||||
android:theme="@style/Theme.ALN">
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
@@ -72,14 +84,30 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ALN">
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="librepods"
|
||||
android:host="add-magic-keys" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".QuickSettingsDialogActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TransparentDialog"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".services.AirPodsService"
|
||||
android:enabled="true"
|
||||
@@ -107,6 +135,16 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
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)
|
||||
423
android/app/src/main/cpp/l2c_fcr_hook.cpp
Normal file
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
* 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"
|
||||
|
||||
#define LOG_TAG "AirPodsHook"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
||||
|
||||
struct t_l2c_lcb;
|
||||
typedef struct _BT_HDR {
|
||||
uint16_t event;
|
||||
uint16_t len;
|
||||
uint16_t offset;
|
||||
uint16_t layer_specific;
|
||||
uint8_t data[];
|
||||
} BT_HDR;
|
||||
|
||||
typedef struct {
|
||||
uint8_t mode;
|
||||
uint8_t tx_win_sz;
|
||||
uint8_t max_transmit;
|
||||
uint16_t rtrans_tout;
|
||||
uint16_t mon_tout;
|
||||
uint16_t mps;
|
||||
} tL2CAP_FCR;
|
||||
|
||||
// Flow spec structure
|
||||
typedef struct {
|
||||
uint8_t qos_present;
|
||||
uint8_t flow_direction;
|
||||
uint8_t service_type;
|
||||
uint32_t token_rate;
|
||||
uint32_t token_bucket_size;
|
||||
uint32_t peak_bandwidth;
|
||||
uint32_t latency;
|
||||
uint32_t delay_variation;
|
||||
} FLOW_SPEC;
|
||||
|
||||
// Configuration info structure
|
||||
typedef struct {
|
||||
uint16_t result;
|
||||
uint16_t mtu_present;
|
||||
uint16_t mtu;
|
||||
uint16_t flush_to_present;
|
||||
uint16_t flush_to;
|
||||
uint16_t qos_present;
|
||||
FLOW_SPEC qos;
|
||||
uint16_t fcr_present;
|
||||
tL2CAP_FCR fcr;
|
||||
uint16_t fcs_present;
|
||||
uint16_t fcs;
|
||||
uint16_t ext_flow_spec_present;
|
||||
FLOW_SPEC ext_flow_spec;
|
||||
} tL2CAP_CFG_INFO;
|
||||
|
||||
// Basic L2CAP link control block
|
||||
typedef struct {
|
||||
bool wait_ack;
|
||||
// Other FCR fields - not needed for our specific hook
|
||||
} tL2C_FCRB;
|
||||
|
||||
// Forward declarations for needed types
|
||||
struct t_l2c_rcb;
|
||||
struct t_l2c_lcb;
|
||||
|
||||
typedef struct t_l2c_ccb {
|
||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
||||
uint16_t local_cid; // Local CID
|
||||
uint16_t remote_cid; // Remote CID
|
||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
||||
uint8_t ccb_priority; // Channel priority
|
||||
uint16_t tx_mps; // MPS for outgoing messages
|
||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
||||
// State variables
|
||||
bool in_use; // True when channel active
|
||||
uint8_t chnl_state; // Channel state
|
||||
uint8_t local_id; // Transaction ID for local trans
|
||||
uint8_t remote_id; // Transaction ID for remote
|
||||
uint8_t timer_entry; // Timer entry
|
||||
uint8_t is_flushable; // True if flushable
|
||||
// Configuration variables
|
||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
||||
uint16_t config_done; // Configuration bitmask
|
||||
uint16_t remote_config_rsp_result; // Remote config response result
|
||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
||||
// Additional control fields
|
||||
uint8_t remote_credit_count; // Credits sent to peer
|
||||
tL2C_FCRB fcrb; // FCR info
|
||||
bool ecoc; // Enhanced Credit-based mode
|
||||
} tL2C_CCB;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
||||
}
|
||||
|
||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
||||
original_l2c_csm_config(p_ccb, event, p_data);
|
||||
|
||||
// Check if this happens during CONFIG_RSP event handling
|
||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
||||
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
|
||||
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
|
||||
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
|
||||
}
|
||||
}
|
||||
|
||||
// Replacement function that does nothing
|
||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
||||
// Just return without doing anything
|
||||
return;
|
||||
}
|
||||
|
||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
||||
const char* property_name = "persist.librepods.hook_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read hook offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse offset from property value: %s", value);
|
||||
}
|
||||
|
||||
LOGI("Using hardcoded fallback offset");
|
||||
return 0x00a55e30;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cCsmConfigOffset() {
|
||||
const char* property_name = "persist.librepods.csm_config_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2c_csm_config offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t loadL2cuSendPeerInfoReqOffset() {
|
||||
const char* property_name = "persist.librepods.peer_info_req_offset";
|
||||
char value[PROP_VALUE_MAX] = {0};
|
||||
|
||||
int len = __system_property_get(property_name, value);
|
||||
if (len > 0) {
|
||||
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
|
||||
uintptr_t offset;
|
||||
char* endptr = nullptr;
|
||||
|
||||
const char* parse_start = value;
|
||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
||||
parse_start = value + 2;
|
||||
}
|
||||
|
||||
errno = 0;
|
||||
offset = strtoul(parse_start, &endptr, 16);
|
||||
|
||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
||||
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
|
||||
return offset;
|
||||
}
|
||||
|
||||
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
|
||||
}
|
||||
|
||||
// Return 0 if not found - we'll skip this hook
|
||||
return 0;
|
||||
}
|
||||
|
||||
uintptr_t getModuleBase(const char *module_name) {
|
||||
FILE *fp;
|
||||
char line[1024];
|
||||
uintptr_t base_addr = 0;
|
||||
|
||||
fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("Failed to open /proc/self/maps");
|
||||
return 0;
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, module_name)) {
|
||||
char *start_addr_str = line;
|
||||
char *end_addr_str = strchr(line, '-');
|
||||
if (end_addr_str) {
|
||||
*end_addr_str = '\0';
|
||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return base_addr;
|
||||
}
|
||||
|
||||
bool findAndHookFunction(const char *library_name) {
|
||||
if (!hook_func) {
|
||||
LOGE("Hook function not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
uintptr_t base_addr = getModuleBase(library_name);
|
||||
if (!base_addr) {
|
||||
LOGE("Failed to get base address of %s", library_name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load all offsets from system properties - no hardcoding
|
||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
||||
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
|
||||
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
|
||||
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
|
||||
|
||||
bool success = false;
|
||||
|
||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
||||
if (l2c_fcr_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
||||
l2c_fcr_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
||||
return false;
|
||||
}
|
||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
||||
success = true;
|
||||
} else {
|
||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hook l2cu_process_our_cfg_req if offset is available
|
||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
||||
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
|
||||
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
||||
}
|
||||
|
||||
// Hook l2c_csm_config if offset is available
|
||||
if (l2c_csm_config_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
|
||||
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
|
||||
l2c_csm_config_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2c_csm_config, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2c_csm_config");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2c_csm_config hook as offset is not available");
|
||||
}
|
||||
|
||||
// Hook l2cu_send_peer_info_req if offset is available
|
||||
if (l2cu_send_peer_info_req_offset > 0) {
|
||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
|
||||
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
|
||||
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
|
||||
|
||||
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
|
||||
if (result != 0) {
|
||||
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
|
||||
// Continue even if this hook fails
|
||||
} else {
|
||||
LOGI("Successfully hooked l2cu_send_peer_info_req");
|
||||
}
|
||||
} else {
|
||||
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth JNI library function");
|
||||
}
|
||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth QTI library function");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
LOGI("L2C FCR Hook module initialized");
|
||||
|
||||
hook_func = entries->hook_func;
|
||||
|
||||
return on_library_loaded;
|
||||
}
|
||||
28
android/app/src/main/cpp/l2c_fcr_hook.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
||||
|
||||
typedef int (*UnhookFunType)(void *func);
|
||||
|
||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
||||
|
||||
typedef struct {
|
||||
uint32_t version;
|
||||
HookFunType hook_func;
|
||||
UnhookFunType unhook_func;
|
||||
} NativeAPIEntries;
|
||||
|
||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
||||
|
||||
typedef struct t_l2c_ccb tL2C_CCB;
|
||||
typedef struct t_l2c_lcb tL2C_LCB;
|
||||
|
||||
uintptr_t loadHookOffset(const char* package_name);
|
||||
uintptr_t getModuleBase(const char *module_name);
|
||||
uintptr_t loadL2cuProcessCfgReqOffset();
|
||||
uintptr_t loadL2cCsmConfigOffset();
|
||||
uintptr_t loadL2cuSendPeerInfoReqOffset();
|
||||
bool findAndHookFunction(const char *library_path);
|
||||
@@ -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,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,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,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,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()
|
||||
}
|
||||
}
|
||||
@@ -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,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
@@ -47,7 +47,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
import java.util.UUID
|
||||
|
||||
@@ -57,7 +57,7 @@ class CustomDevice : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ALNTheme {
|
||||
LibrePodsTheme {
|
||||
val connect = remember { mutableStateOf(false) }
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -0,0 +1,734 @@
|
||||
/*
|
||||
* 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.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.DebugScreen
|
||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.screens.LongPress
|
||||
import me.kavishdevar.librepods.screens.Onboarding
|
||||
import me.kavishdevar.librepods.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.CrossDevice
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor",
|
||||
MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply()
|
||||
Main()
|
||||
}
|
||||
}
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent) {
|
||||
val data: Uri? = intent.data
|
||||
|
||||
if (data != null && data.scheme == "librepods") {
|
||||
when (data.host) {
|
||||
"add-magic-keys" -> {
|
||||
// Extract query parameters
|
||||
val queryParams = data.queryParameterNames
|
||||
queryParams.forEach { param ->
|
||||
val value = data.getQueryParameter(param)
|
||||
// Handle your parameters here
|
||||
Log.d("LibrePods", "Parameter: $param = $value")
|
||||
}
|
||||
|
||||
// Process the magic keys addition
|
||||
handleAddMagicKeys(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddMagicKeys(uri: Uri) {
|
||||
val context = this
|
||||
val sharedPreferences = getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val irkHex = uri.getQueryParameter("irk")
|
||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
||||
|
||||
try {
|
||||
if (irkHex != null && validateHexInput(irkHex)) {
|
||||
val irkBytes = hexStringToByteArray(irkHex)
|
||||
val irkBase64 = Base64.encode(irkBytes)
|
||||
sharedPreferences.edit().putString("IRK", irkBase64).apply()
|
||||
}
|
||||
|
||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
||||
sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply()
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
||||
val result = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
||||
result[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val isConnected = remember { mutableStateOf(false) }
|
||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
||||
val context = LocalContext.current
|
||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||
|
||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE"
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
)
|
||||
}
|
||||
val otherPermissions = listOf(
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
val allPermissions = bluetoothPermissions + otherPermissions
|
||||
|
||||
val permissionState = rememberMultiplePermissionsState(
|
||||
permissions = allPermissions
|
||||
)
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||
}
|
||||
|
||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||
val context = LocalContext.current
|
||||
context.startService(Intent(context, AirPodsService::class.java))
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "CrossDeviceIsAvailable") {
|
||||
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
|
||||
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
|
||||
}
|
||||
}
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
|
||||
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
|
||||
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
|
||||
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
}
|
||||
) {
|
||||
composable("settings") {
|
||||
if (airPodsService.value != null) {
|
||||
AirPodsSettingsScreen(
|
||||
dev = airPodsService.value?.device,
|
||||
service = airPodsService.value!!,
|
||||
navController = navController,
|
||||
isConnected = isConnected.value,
|
||||
isRemotelyConnected = isRemotelyConnected.value
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("debug") {
|
||||
DebugScreen(navController = navController)
|
||||
}
|
||||
composable("long_press/{bud}") { navBackStackEntry ->
|
||||
LongPress(
|
||||
navController = navController,
|
||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
||||
)
|
||||
}
|
||||
composable("rename") { navBackStackEntry ->
|
||||
RenameScreen(navController)
|
||||
}
|
||||
composable("app_settings") {
|
||||
AppSettingsScreen(navController)
|
||||
}
|
||||
composable("troubleshooting") {
|
||||
TroubleshootingScreen(navController)
|
||||
}
|
||||
composable("head_tracking") {
|
||||
HeadTrackingScreen(navController)
|
||||
}
|
||||
composable("onboarding") {
|
||||
Onboarding(navController, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serviceConnection = remember {
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService.value = binder.getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (airPodsService.value?.isConnectedLocally == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
PermissionsScreen(
|
||||
permissionState = permissionState,
|
||||
canDrawOverlays = canDrawOverlays,
|
||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionState: MultiplePermissionsState,
|
||||
canDrawOverlays: Boolean,
|
||||
onOverlaySettingsReturn: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uDBC2\uDEB7",
|
||||
style = TextStyle(
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.scale(pulseScale)
|
||||
) {
|
||||
val radius = size.minDimension / 2.2f
|
||||
val centerX = size.width / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
rotate(degrees = 45f) {
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.1f),
|
||||
radius = radius * 1.3f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.2f),
|
||||
radius = radius * 1.1f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Permission Required",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "The following permissions are required to use the app. Please grant them to continue.",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PermissionCard(
|
||||
title = "Bluetooth Permissions",
|
||||
description = "Required to communicate with your AirPods",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("BLUETOOTH")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Notification Permission",
|
||||
description = "To show battery status",
|
||||
icon = Icons.Default.Notifications,
|
||||
isGranted = permissionState.permissions.find {
|
||||
it.permission == "android.permission.POST_NOTIFICATIONS"
|
||||
}?.status?.isGranted == true,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Phone Permissions",
|
||||
description = "For answering calls with Head Gestures",
|
||||
icon = Icons.Default.Phone,
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("PHONE") || it.permission.contains("CALLS")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Display Over Other Apps",
|
||||
description = "For popup animations when AirPods connect",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
|
||||
isGranted = canDrawOverlays,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchMultiplePermissionRequest() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Ask for regular permissions",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
)
|
||||
context.startActivity(intent)
|
||||
onOverlaySettingsReturn()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (canDrawOverlays) Color.Gray else accentColor
|
||||
),
|
||||
enabled = !canDrawOverlays,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!canDrawOverlays && basicPermissionsGranted) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val editor = context.getSharedPreferences("settings", MODE_PRIVATE).edit()
|
||||
editor.putBoolean("overlay_permission_skipped", true)
|
||||
editor.apply()
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF757575)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue without overlay",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ImageVector,
|
||||
isGranted: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
accentColor: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = if (isGranted) accentColor else Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isGranted) "✓" else "!",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||
|
||||
private var airPodsService: AirPodsService? = null
|
||||
private var isBound = false
|
||||
|
||||
private var isNoiseControlExpandedState by mutableStateOf(false)
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService = binder.getService()
|
||||
isBound = true
|
||||
Log.d("QSActivity", "Service bound")
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
isBound = false
|
||||
airPodsService = null
|
||||
Log.d("QSActivity", "Service unbound")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
||||
window.setGravity(Gravity.BOTTOM)
|
||||
|
||||
Intent(this, AirPodsService::class.java).also { intent ->
|
||||
bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isBound) {
|
||||
unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableDismissBox(
|
||||
onDismiss: () -> Unit,
|
||||
onlyCollapseWhenClicked: () -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = 400f
|
||||
|
||||
val animatedOffset = remember { Animatable(0f) }
|
||||
val animatedScale = remember { Animatable(1f) }
|
||||
val animatedAlpha = remember { Animatable(1f) }
|
||||
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
targetValue = if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
|
||||
1f - dragProgress
|
||||
} else 1f,
|
||||
label = "BackgroundFade"
|
||||
)
|
||||
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) {
|
||||
if (abs(dragOffset) < dismissThreshold) {
|
||||
val springSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessHigh,
|
||||
visibilityThreshold = 0.1f
|
||||
)
|
||||
launch { animatedOffset.animateTo(0f, springSpec) }
|
||||
launch { animatedScale.animateTo(1f, springSpec) }
|
||||
launch { animatedAlpha.animateTo(1f, tween(100)) }
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragOffset, isDragging) {
|
||||
if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||
|
||||
animatedOffset.snapTo(dragOffset)
|
||||
animatedScale.snapTo(1f - dragProgress * 0.3f)
|
||||
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { isDragging = true },
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (abs(dragOffset) > dismissThreshold) {
|
||||
coroutineScope.launch {
|
||||
val direction = if (dragOffset > 0) 1f else -1f
|
||||
|
||||
launch {
|
||||
animatedOffset.animateTo(
|
||||
direction * 1500f,
|
||||
tween(350, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
launch { animatedScale.animateTo(0.7f, tween(350)) }
|
||||
launch { animatedAlpha.animateTo(0f, tween(250)) }
|
||||
|
||||
kotlinx.coroutines.delay(350)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = { isDragging = false },
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onlyCollapseWhenClicked()
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
translationY = animatedOffset.value,
|
||||
scaleX = animatedScale.value,
|
||||
scaleY = animatedScale.value,
|
||||
alpha = animatedAlpha.value
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun NewControlCenterDialogContent(
|
||||
service: AirPodsService?,
|
||||
isNoiseControlExpanded: Boolean,
|
||||
onNoiseControlExpandedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val textColor = Color.White
|
||||
|
||||
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
|
||||
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
|
||||
val availableModes = remember(isOffModeEnabled) {
|
||||
mutableListOf(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
NoiseControlMode.NOISE_CANCELLATION
|
||||
).apply {
|
||||
if (isOffModeEnabled) {
|
||||
add(0, NoiseControlMode.OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
|
||||
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
|
||||
val animatedVolumeFraction by animateFloatAsState(
|
||||
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "VolumeAnimation"
|
||||
)
|
||||
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
|
||||
var isDraggingVolume by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
|
||||
if (!isDraggingVolume) {
|
||||
liveDragFraction = animatedVolumeFraction
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(service, availableModes) {
|
||||
val ancReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
|
||||
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
|
||||
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (availableModes.contains(newMode)) {
|
||||
currentAncMode = newMode
|
||||
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(ancReceiver, filter)
|
||||
}
|
||||
|
||||
service?.let {
|
||||
val initialModeOrdinal = it.getANC().minus(1)
|
||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (!availableModes.contains(initialMode)) {
|
||||
initialMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
currentAncMode = initialMode
|
||||
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
|
||||
}
|
||||
|
||||
onDispose {
|
||||
context.unregisterReceiver(ancReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val volumeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
|
||||
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (newVolume != currentVolumeInt) {
|
||||
currentVolumeInt = newVolume
|
||||
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
|
||||
context.registerReceiver(volumeReceiver, filter)
|
||||
onDispose {
|
||||
context.unregisterReceiver(volumeReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 24.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent()
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (service != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Device Icon",
|
||||
tint = textColor.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = deviceName,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
VerticalVolumeSlider(
|
||||
displayFraction = animatedVolumeFraction,
|
||||
maxVolume = maxVolume,
|
||||
onVolumeChange = { newVolume ->
|
||||
currentVolumeInt = newVolume
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
|
||||
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
|
||||
},
|
||||
initialFraction = animatedVolumeFraction,
|
||||
onDragStateChange = { dragging -> isDraggingVolume = dragging },
|
||||
baseSliderHeight = 400.dp,
|
||||
baseSliderWidth = 145.dp,
|
||||
baseCornerRadius = 48.dp,
|
||||
maxStretchFactor = 1.15f,
|
||||
minCompressionFactor = 0.875f,
|
||||
stretchSensitivity = 0.3f,
|
||||
compressionSensitivity = 0.3f,
|
||||
cornerRadiusChangeFactor = -0.5f,
|
||||
directionalStretchRatio = 0.75f,
|
||||
modifier = Modifier
|
||||
.width(145.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 72.dp)
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isNoiseControlExpanded,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "NoiseControlCrossfade"
|
||||
) { expanded ->
|
||||
if (expanded) {
|
||||
ControlCenterNoiseControlSegmentedButton(
|
||||
availableModes = availableModes,
|
||||
selectedMode = currentAncMode,
|
||||
onModeSelected = { newMode ->
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
value = newMode.ordinal + 1
|
||||
)
|
||||
currentAncMode = newMode
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
|
||||
AdaptiveRainbowBrush
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = noiseControlButtonBrush ?:
|
||||
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
|
||||
)
|
||||
.clickable(
|
||||
onClick = { onNoiseControlExpandedChange(true) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = getModeIconRes(currentAncMode)),
|
||||
contentDescription = getModeLabel(currentAncMode),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = getModeLabel(currentAncMode),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val newState = !isConvAwarenessEnabled
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
value = newState
|
||||
)
|
||||
isConvAwarenessEnabled = newState
|
||||
},
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Conversational Awareness",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Conversational\nAwareness",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Loading...", color = textColor)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -38,22 +38,23 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun AccessibilitySettings() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val service = ServiceManager.getService()!!
|
||||
Text(
|
||||
text = stringResource(R.string.accessibility).uppercase(),
|
||||
style = TextStyle(
|
||||
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
|
||||
)
|
||||
)
|
||||
|
||||
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
|
||||
ToneVolumeSlider()
|
||||
}
|
||||
|
||||
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
|
||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to "Default",
|
||||
1.toByte() to "Slower",
|
||||
2.toByte() to "Slowest"
|
||||
)
|
||||
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Press Speed",
|
||||
options = pressSpeedOptions,
|
||||
selectedOption = selectedPressSpeed,
|
||||
onOptionSelected = {
|
||||
selectedPressSpeed = it
|
||||
service.setPressSpeed(pressSpeedOptions.indexOf(it))
|
||||
options = pressSpeedOptions.values.toList(),
|
||||
selectedOption = selectedPressSpeed.toString(),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressSpeed = newValue
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
|
||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to "Default",
|
||||
1.toByte() to "Slower",
|
||||
2.toByte() to "Slowest"
|
||||
)
|
||||
|
||||
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Press and Hold Duration",
|
||||
options = pressAndHoldDurationOptions,
|
||||
selectedOption = selectedPressAndHoldDuration,
|
||||
onOptionSelected = {
|
||||
selectedPressAndHoldDuration = it
|
||||
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
|
||||
options = pressAndHoldDurationOptions.values.toList(),
|
||||
selectedOption = selectedPressAndHoldDuration.toString(),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressAndHoldDuration = newValue
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
|
||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
|
||||
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
|
||||
1.toByte() to "Default",
|
||||
2.toByte() to "Longer",
|
||||
3.toByte() to "Longest"
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
|
||||
DropdownMenuComponent(
|
||||
label = "Volume Swipe Speed",
|
||||
options = volumeSwipeSpeedOptions,
|
||||
selectedOption = selectedVolumeSwipeSpeed,
|
||||
onOptionSelected = {
|
||||
selectedVolumeSwipeSpeed = it
|
||||
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
|
||||
options = volumeSwipeSpeedOptions.values.toList(),
|
||||
selectedOption = selectedVolumeSwipeSpeed.toString(),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedVolumeSwipeSpeed = newValue
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
|
||||
SinglePodANCSwitch()
|
||||
VolumeControlSwitch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
|
||||
@Preview
|
||||
@Composable
|
||||
fun AccessibilitySettingsPreview() {
|
||||
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
AccessibilitySettings()
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun AdaptiveStrengthSlider() {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
val service = ServiceManager.getService()!!
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("adaptive_strength")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sliderValue.floatValue) {
|
||||
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
|
||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
||||
value = (100 - sliderValue.floatValue).toInt()
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
||||
@Preview
|
||||
@Composable
|
||||
fun AdaptiveStrengthSliderPreview() {
|
||||
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
}
|
||||
AdaptiveStrengthSlider()
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -30,18 +30,17 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun AudioSettings() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
|
||||
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
|
||||
ConversationalAwarenessSwitch()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
||||
)
|
||||
)
|
||||
|
||||
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
|
||||
AdaptiveStrengthSlider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
||||
AudioSettings()
|
||||
}
|
||||
@@ -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.animation.core.animateFloatAsState
|
||||
@@ -48,7 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
|
||||
@@ -1,22 +1,24 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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
|
||||
@@ -44,12 +46,13 @@ import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
import me.kavishdevar.aln.utils.Battery
|
||||
import me.kavishdevar.aln.utils.BatteryComponent
|
||||
import me.kavishdevar.aln.utils.BatteryStatus
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun ConversationalAwarenessSwitch() {
|
||||
val service = ServiceManager.getService()!!
|
||||
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var conversationalAwarenessEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
conversationEnabledValue == 1.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
fun updateConversationalAwareness(enabled: Boolean) {
|
||||
conversationalAwarenessEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
|
||||
service.setCAEnabled(enabled)
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitchPreview() {
|
||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
ConversationalAwarenessSwitch()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
class DropdownItem(val name: String, val onSelect: () -> Unit) {
|
||||
fun select() {
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomDropdown(name: String, description: String = "", items: List<DropdownItem>) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var offset by remember { mutableStateOf(IntOffset.Zero) }
|
||||
var popupHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
val animatedHeight by animateDpAsState(
|
||||
targetValue = if (expanded) popupHeight else 0.dp,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = if (expanded) 1f else 0f,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.Transparent
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
expanded = true
|
||||
}
|
||||
.onGloballyPositioned { coordinates ->
|
||||
val windowPosition = coordinates.localToWindow(Offset.Zero)
|
||||
offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1
|
||||
)
|
||||
if (description.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 12.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "\uDBC0\uDD8F",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = offset ,
|
||||
properties = PopupProperties(focusable = true),
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(backgroundColor, RoundedCornerShape(8.dp))
|
||||
.padding(8.dp)
|
||||
.widthIn(max = 50.dp)
|
||||
.height(animatedHeight)
|
||||
.scale(animatedScale)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
popupHeight = with(density) { coordinates.size.height.toDp() }
|
||||
}
|
||||
) {
|
||||
items.forEach { item ->
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
item.select()
|
||||
expanded = false
|
||||
}
|
||||
.padding(8.dp),
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CustomDropdownPreview() {
|
||||
CustomDropdown(
|
||||
name = "Volume Swipe Speed",
|
||||
items = listOf(
|
||||
DropdownItem("Always On") { },
|
||||
DropdownItem("Off") { },
|
||||
DropdownItem("Only when speaking") { }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.SharedPreferences
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -45,18 +47,41 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) {
|
||||
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||
val snakeCasedName =
|
||||
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||
var checked by remember { mutableStateOf(default) }
|
||||
|
||||
if (controlCommandIdentifier != null) {
|
||||
checked = service!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == controlCommandIdentifier
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
}
|
||||
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
fun cb() {
|
||||
if (controlCommandIdentifier == null) {
|
||||
sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply()
|
||||
}
|
||||
if (functionName != null && service != null) {
|
||||
val method =
|
||||
service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, checked)
|
||||
}
|
||||
if (controlCommandIdentifier != null) {
|
||||
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(sharedPreferences) {
|
||||
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
||||
}
|
||||
@@ -73,13 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
|
||||
},
|
||||
onTap = {
|
||||
checked = !checked
|
||||
sharedPreferences
|
||||
.edit()
|
||||
.putBoolean(snakeCasedName, checked)
|
||||
.apply()
|
||||
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, checked)
|
||||
cb()
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -97,9 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
checked = it
|
||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, it)
|
||||
cb()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -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.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -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
|
||||
@@ -312,9 +322,10 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> null
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
newMode?.let { onModeSelected(it) }
|
||||
// Call onModeSelected which now handles service call but not callback
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -430,4 +441,4 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
NoiseControlSettings(AirPodsService())
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -56,7 +57,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
|
||||
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
|
||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
|
||||
style = TextStyle(
|
||||
@@ -81,6 +101,8 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -120,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
text = leftActionText,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
@@ -180,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
text = rightActionText,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
@@ -1,24 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun SinglePodANCSwitch() {
|
||||
val service = ServiceManager.getService()!!
|
||||
val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var singleANCEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("single_anc", true)
|
||||
singleANCEnabledValue == 1.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSingleEnabled(enabled: Boolean) {
|
||||
singleANCEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
|
||||
service.setNoiseCancellationWithOnePod(enabled)
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
|
||||
@Preview
|
||||
@Composable
|
||||
fun SinglePodANCSwitchPreview() {
|
||||
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
SinglePodANCSwitch()
|
||||
}
|
||||
@@ -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.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -1,24 +1,26 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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)
|
||||
|
||||
import android.content.SharedPreferences
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -50,22 +50,23 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("tone_volume")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sliderValue.floatValue) {
|
||||
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
|
||||
}
|
||||
fun ToneVolumeSlider() {
|
||||
val service = ServiceManager.getService()!!
|
||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
val sliderValue = remember { mutableFloatStateOf(
|
||||
sliderValueFromAACP?.toFloat() ?: -1f
|
||||
) }
|
||||
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
@@ -95,11 +95,16 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
service.setToneVolume(volume = it.toInt())
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
||||
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
|
||||
0x50.toByte()
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
||||
@Preview
|
||||
@Composable
|
||||
fun ToneVolumeSliderPreview() {
|
||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
ToneVolumeSlider()
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,25 @@
|
||||
/*
|
||||
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
|
||||
*
|
||||
* Copyright (C) 2024 Kavish Devar
|
||||
*
|
||||
* LibrePods - AirPods liberated from 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.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
fun VolumeControlSwitch() {
|
||||
val service = ServiceManager.getService()!!
|
||||
val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
var volumeControlEnabled by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getBoolean("volume_control", true)
|
||||
volumeControlEnabledValue == 1.toByte()
|
||||
)
|
||||
}
|
||||
fun updateVolumeControlEnabled(enabled: Boolean) {
|
||||
volumeControlEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
|
||||
service.setVolumeControl(enabled)
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
|
||||
enabled
|
||||
)
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
|
||||
@Preview
|
||||
@Composable
|
||||
fun VolumeControlSwitchPreview() {
|
||||
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
VolumeControlSwitch()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
val prefixPattern = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00
|
||||
)
|
||||
|
||||
for (i in prefixPattern.indices) {
|
||||
if (data[i] != prefixPattern[i].toByte()) return false
|
||||
}
|
||||
|
||||
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
|
||||
|
||||
if (data[11] != 0x00.toByte()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.constants.StemAction.entries
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
|
||||
enum class StemAction {
|
||||
PLAY_PAUSE,
|
||||
PREVIOUS_TRACK,
|
||||
NEXT_TRACK,
|
||||
CAMERA_SHUTTER,
|
||||
DIGITAL_ASSISTANT,
|
||||
CYCLE_NOISE_CONTROL_MODES;
|
||||
companion object {
|
||||
fun fromString(action: String): StemAction? {
|
||||
return entries.find { it.name == action }
|
||||
}
|
||||
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
||||
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,484 @@
|
||||
/*
|
||||
* 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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.AccessibilitySettings
|
||||
import me.kavishdevar.librepods.composables.AudioSettings
|
||||
import me.kavishdevar.librepods.composables.BatteryView
|
||||
import me.kavishdevar.librepods.composables.IndependentToggle
|
||||
import me.kavishdevar.librepods.composables.NameField
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
isLocallyConnected = service.isConnectedLocally
|
||||
}
|
||||
|
||||
val nameChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "name") {
|
||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val hazeState = remember { HazeState() }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleRemoteConnection(connected: Boolean) {
|
||||
isRemotelyConnected = connected
|
||||
}
|
||||
|
||||
fun showSnackbar(message: String) {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val connectionReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(true)
|
||||
}
|
||||
}
|
||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(false)
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = true
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = false
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(connectionReceiver, filter)
|
||||
}
|
||||
onDispose {
|
||||
try {
|
||||
context.unregisterReceiver(connectionReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
Scaffold(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(
|
||||
0xFF000000
|
||||
) else Color(
|
||||
0xFFF2F2F7
|
||||
),
|
||||
topBar = {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val mDensity = remember { mutableFloatStateOf(1f) }
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = deviceName.text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha =
|
||||
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
|
||||
})
|
||||
.drawBehind {
|
||||
mDensity.floatValue = density
|
||||
val strokeWidth = 0.7.dp.value * density
|
||||
val y = size.height - strokeWidth / 2
|
||||
if (verticalScrollState.value > 60.dp.value * density) {
|
||||
drawLine(
|
||||
if (darkMode) Color.DarkGray else Color.LightGray,
|
||||
Offset(0f, y),
|
||||
Offset(size.width, y),
|
||||
strokeWidth
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
if (isRemotelyConnected) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showSnackbar("Connected remotely to AirPods via Linux.")
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "Info",
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate("app_settings")
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
if (isLocallyConnected || isRemotelyConnected) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(
|
||||
state = verticalScrollState,
|
||||
enabled = true,
|
||||
)
|
||||
) {
|
||||
Spacer(Modifier.height(75.dp))
|
||||
LaunchedEffect(service) {
|
||||
service.let {
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
|
||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
||||
})
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
|
||||
putExtra("data", it.getANC())
|
||||
})
|
||||
}
|
||||
}
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
|
||||
BatteryView(service = service)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Show BLE-only mode indicator
|
||||
if (bleOnlyMode) {
|
||||
Text(
|
||||
text = "BLE-only mode - advanced features disabled",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Only show name field when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
NameField(
|
||||
name = stringResource(R.string.name),
|
||||
value = deviceName.text,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
// Only show L2CAP-dependent features when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
NoiseControlSettings(service = service)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.head_gestures).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
NavigationButton(to = "head_tracking", "Head Tracking", navController)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PressAndHoldSettings(navController = navController)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AudioSettings()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Off Listening Mode",
|
||||
service = service,
|
||||
sharedPreferences = sharedPreferences,
|
||||
default = false,
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccessibilitySettings()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Automatic Ear Detection",
|
||||
service = service,
|
||||
functionName = "setEarDetection",
|
||||
sharedPreferences = sharedPreferences,
|
||||
default = true,
|
||||
)
|
||||
|
||||
// Only show debug when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NavigationButton("debug", "Debug", navController)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
.verticalScroll(
|
||||
state = verticalScrollState,
|
||||
enabled = true,
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "AirPods not connected",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "Please connect your AirPods to access settings.",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = { navController.navigate("troubleshooting") },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Troubleshoot Connection",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AirPodsSettingsScreenPreview() {
|
||||
Column (
|
||||
modifier = Modifier.height(2000.dp)
|
||||
) {
|
||||
LibrePodsTheme (
|
||||
darkTheme = true
|
||||
) {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
/*
|
||||
* 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.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class PacketInfo(
|
||||
val type: String,
|
||||
val description: String,
|
||||
val rawData: String,
|
||||
val parsedData: Map<String, String> = emptyMap(),
|
||||
val isUnknown: Boolean = false
|
||||
)
|
||||
|
||||
fun parsePacket(message: String): PacketInfo {
|
||||
val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9)
|
||||
val bytes = rawData.split(" ").mapNotNull {
|
||||
it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte()
|
||||
}.toByteArray()
|
||||
|
||||
val airPodsService = ServiceManager.getService()
|
||||
if (airPodsService != null) {
|
||||
return when {
|
||||
message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData)
|
||||
airPodsService.batteryNotification.isBatteryData(bytes) -> {
|
||||
val batteryInfo = mutableMapOf<String, String>()
|
||||
airPodsService.batteryNotification.setBattery(bytes)
|
||||
val batteries = airPodsService.batteryNotification.getBattery()
|
||||
val batteryInfoString = batteries.joinToString(", ") { battery ->
|
||||
"${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
batteries.forEach { battery ->
|
||||
if (battery.status != BatteryStatus.DISCONNECTED) {
|
||||
batteryInfo[battery.getComponentName() ?: "Unknown"] =
|
||||
"${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Battery",
|
||||
batteryInfoString,
|
||||
rawData,
|
||||
batteryInfo
|
||||
)
|
||||
}
|
||||
airPodsService.ancNotification.isANCData(bytes) -> {
|
||||
airPodsService.ancNotification.setStatus(bytes)
|
||||
val mode = when (airPodsService.ancNotification.status) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Noise Control",
|
||||
"Mode: $mode",
|
||||
rawData,
|
||||
mapOf("Mode" to mode)
|
||||
)
|
||||
}
|
||||
airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> {
|
||||
airPodsService.earDetectionNotification.setStatus(bytes)
|
||||
val status = airPodsService.earDetectionNotification.status
|
||||
val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear"
|
||||
val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear"
|
||||
|
||||
PacketInfo(
|
||||
"Ear Detection",
|
||||
"Primary: $primaryStatus, Secondary: $secondaryStatus",
|
||||
rawData,
|
||||
mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus)
|
||||
)
|
||||
}
|
||||
airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> {
|
||||
airPodsService.conversationAwarenessNotification.setData(bytes)
|
||||
val statusMap = mapOf(
|
||||
1.toByte() to "Started speaking",
|
||||
2.toByte() to "Speaking",
|
||||
8.toByte() to "Stopped speaking",
|
||||
9.toByte() to "Not speaking"
|
||||
)
|
||||
val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?:
|
||||
"Unknown (${airPodsService.conversationAwarenessNotification.status})"
|
||||
|
||||
PacketInfo(
|
||||
"Conversation Awareness",
|
||||
"Status: $status",
|
||||
rawData,
|
||||
mapOf("Status" to status)
|
||||
)
|
||||
}
|
||||
isHeadTrackingData(bytes) -> {
|
||||
val horizontal = if (bytes.size >= 53)
|
||||
"${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown"
|
||||
val vertical = if (bytes.size >= 55)
|
||||
"${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown"
|
||||
|
||||
PacketInfo(
|
||||
"Head Tracking",
|
||||
"Position data",
|
||||
rawData,
|
||||
mapOf("Horizontal" to horizontal, "Vertical" to vertical)
|
||||
)
|
||||
}
|
||||
else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
} else {
|
||||
return if (message.startsWith("Sent")) {
|
||||
parseOutgoingPacket(bytes, rawData)
|
||||
} else {
|
||||
PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
|
||||
if (bytes.size < 7) {
|
||||
return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
|
||||
return when {
|
||||
bytes.size >= 16 &&
|
||||
bytes[0] == 0x00.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() -> {
|
||||
PacketInfo("Handshake", "Initial handshake with AirPods", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x0d.toByte() -> {
|
||||
val mode = when (bytes[7].toInt()) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x28.toByte() -> {
|
||||
val mode = if (bytes[7].toInt() == 1) "On" else "Off"
|
||||
PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size > 10 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x17.toByte() -> {
|
||||
val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop"
|
||||
PacketInfo("Head Tracking", "$action head tracking", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x1A.toByte() -> {
|
||||
PacketInfo("Long Press Config", "Change long press modes", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x4d.toByte() -> {
|
||||
PacketInfo("Feature Request", "Set specific features", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x0f.toByte() -> {
|
||||
PacketInfo("Notifications", "Request notifications", rawData)
|
||||
}
|
||||
|
||||
else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IOSCheckbox(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(24.dp)
|
||||
.clickable { onCheckedChange(!checked) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (checked) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Checked",
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun DebugScreen(navController: NavController) {
|
||||
val hazeState = remember { HazeState() }
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
|
||||
val airPodsService = remember { ServiceManager.getService() }
|
||||
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
|
||||
val shouldScrollToBottom = remember { mutableStateOf(true) }
|
||||
|
||||
val refreshTrigger = remember { mutableStateOf(0) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
while(true) {
|
||||
delay(1000)
|
||||
refreshTrigger.value = refreshTrigger.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
fun copyToClipboard(text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Packet Data", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
LaunchedEffect(packetLogs.size, refreshTrigger.value) {
|
||||
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(packetLogs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Debug") },
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
sharedPreferences.getString("name", "AirPods")!!,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { showMenu.value = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More Options",
|
||||
tint = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMenu.value,
|
||||
onDismissRequest = { showMenu.value = false },
|
||||
modifier = Modifier
|
||||
.width(250.dp)
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Auto-scroll",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IOSCheckbox(
|
||||
checked = shouldScrollToBottom.value,
|
||||
onCheckedChange = { shouldScrollToBottom.value = it }
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
shouldScrollToBottom.value = !shouldScrollToBottom.value
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Clear logs",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Clear logs",
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
ServiceManager.getService()?.clearLogs()
|
||||
expandedItems.value = emptySet()
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha = if (scrollOffset > 0) 1f else 0f
|
||||
}),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.padding(top = paddingValues.calculateTopPadding())
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = {
|
||||
items(packetLogs.size) { index ->
|
||||
val message = packetLogs.elementAt(index)
|
||||
val isSent = message.startsWith("Sent")
|
||||
val isExpanded = expandedItems.value.contains(index)
|
||||
val packetInfo = parsePacket(message)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
expandedItems.value = if (isExpanded) {
|
||||
expandedItems.value - index
|
||||
} else {
|
||||
expandedItems.value + index
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
copyToClipboard(packetInfo.rawData)
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = if (isSent) Color.Green else Color.Red,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (packetInfo.isUnknown) {
|
||||
val shortenedData = packetInfo.rawData.take(60) +
|
||||
(if (packetInfo.rawData.length > 60) "..." else "")
|
||||
shortenedData
|
||||
} else {
|
||||
"${packetInfo.type}: ${packetInfo.description}"
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
)
|
||||
)
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (packetInfo.parsedData.isNotEmpty()) {
|
||||
packetInfo.parsedData.forEach { (key, value) ->
|
||||
Row {
|
||||
Text(
|
||||
text = "$key: ",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Raw: ${packetInfo.rawData}",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val packet = remember { mutableStateOf(TextFieldValue("")) }
|
||||
TextField(
|
||||
value = packet.value,
|
||||
onValueChange = { packet.value = it },
|
||||
label = { Text("Packet") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (packet.value.text.isNotBlank()) {
|
||||
airPodsService?.value?.aacpManager?.sendPacket(
|
||||
packet.value.text
|
||||
.split(" ")
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
)
|
||||
packet.value = TextFieldValue("")
|
||||
focusManager.clearFocus()
|
||||
|
||||
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
listState.scrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
|
||||
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
|
||||
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,859 @@
|
||||
/*
|
||||
* 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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.IndependentToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.HeadTracking
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen(navController: NavController) {
|
||||
DisposableEffect(Unit) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
onDispose {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
}
|
||||
}
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha =
|
||||
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
|
||||
})
|
||||
.drawBehind {
|
||||
mDensity = density
|
||||
val strokeWidth = 0.7.dp.value * density
|
||||
val y = size.height - strokeWidth / 2
|
||||
if (scrollState.value > 60.dp.value * density) {
|
||||
drawLine(
|
||||
if (isDarkTheme) Color.DarkGray else Color.LightGray,
|
||||
Offset(0f, y),
|
||||
Offset(size.width, y),
|
||||
strokeWidth
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.head_tracking),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
if (ServiceManager.getService()?.isHeadTrackingActive == true) ServiceManager.getService()?.stopHeadTracking()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.width(180.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
sharedPreferences.getString("name", "AirPods")!!,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||
isActive = true
|
||||
} else {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||
isActive = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
if (isActive) {
|
||||
ImageVector.Builder(
|
||||
name = "Pause",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
pathBuilder = {
|
||||
moveTo(6f, 5f)
|
||||
lineTo(10f, 5f)
|
||||
lineTo(10f, 19f)
|
||||
lineTo(6f, 19f)
|
||||
lineTo(6f, 5f)
|
||||
moveTo(14f, 5f)
|
||||
lineTo(18f, 5f)
|
||||
lineTo(18f, 19f)
|
||||
lineTo(14f, 19f)
|
||||
lineTo(14f, 5f)
|
||||
}
|
||||
)
|
||||
}.build()
|
||||
} else Icons.Filled.PlayArrow,
|
||||
contentDescription = "Start",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues = paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.dp)
|
||||
.verticalScroll(scrollState)
|
||||
.hazeSource(state = hazeState)
|
||||
) {
|
||||
val sharedPreferences =
|
||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
IndependentToggle(name = "Head Gestures", sharedPreferences = sharedPreferences)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
stringResource(R.string.head_gestures_details),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(0.6f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Head Orientation",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
HeadVisualization()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Acceleration",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
AccelerationPlot()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button (
|
||||
onClick = {
|
||||
gestureText = "Shake your head or nod!"
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
)
|
||||
}
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
ParticleText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
onAnimationComplete = {
|
||||
shouldExplode = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private data class Particle(
|
||||
val initialPosition: Offset,
|
||||
val velocity: Offset,
|
||||
var alpha: Float = 1f
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ParticleText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onAnimationComplete: () -> Unit,
|
||||
) {
|
||||
val particles = remember { mutableStateListOf<Particle>() }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
var isAnimating by remember { mutableStateOf(true) }
|
||||
var textVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxWidth()) {
|
||||
val textLayoutResult = textMeasurer.measure(text, style)
|
||||
val textBounds = textLayoutResult.size
|
||||
val centerX = (size.width - textBounds.width) / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
if (textVisible && particles.isEmpty()) {
|
||||
drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
style = style,
|
||||
topLeft = Offset(centerX, centerY - textBounds.height / 2)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.isEmpty()) {
|
||||
val random = Random(System.currentTimeMillis())
|
||||
for (i in 0..100) {
|
||||
val x = centerX + random.nextFloat() * textBounds.width
|
||||
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
|
||||
val vx = (random.nextFloat() - 0.5f) * 20
|
||||
val vy = (random.nextFloat() - 0.5f) * 20
|
||||
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
|
||||
}
|
||||
textVisible = false
|
||||
}
|
||||
|
||||
particles.forEach { particle ->
|
||||
drawCircle(
|
||||
color = style.color.copy(alpha = particle.alpha),
|
||||
radius = 0.5.dp.toPx(),
|
||||
center = particle.initialPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(text) {
|
||||
while (isAnimating) {
|
||||
delay(16)
|
||||
particles.forEachIndexed { index, particle ->
|
||||
particles[index] = particle.copy(
|
||||
initialPosition = particle.initialPosition + particle.velocity,
|
||||
alpha = (particle.alpha - 0.02f).coerceAtLeast(0f)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.all { it.alpha <= 0f }) {
|
||||
isAnimating = false
|
||||
onAnimationComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadVisualization() {
|
||||
val orientation by HeadTracking.orientation.collectAsState()
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val strokeColor = if (darkTheme) Color.White else Color.Black
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val center = Offset(width / 2, height / 2)
|
||||
val faceRadius = height * 0.35f
|
||||
|
||||
val pitch = Math.toRadians(orientation.pitch.toDouble())
|
||||
val yaw = Math.toRadians(orientation.yaw.toDouble())
|
||||
|
||||
val cosY = cos(yaw).toFloat()
|
||||
val sinY = sin(yaw).toFloat()
|
||||
val cosP = cos(pitch).toFloat()
|
||||
val sinP = sin(pitch).toFloat()
|
||||
|
||||
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val x1 = x * cosY - z * sinY
|
||||
val y1 = y
|
||||
val z1 = x * sinY + z * cosY
|
||||
|
||||
val x2 = x1
|
||||
val y2 = y1 * cosP - z1 * sinP
|
||||
val z2 = y1 * sinP + z1 * cosP
|
||||
|
||||
return Triple(x2, y2, z2)
|
||||
}
|
||||
|
||||
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val scale = 1f + (z / width)
|
||||
return Pair(center.x + x * scale, center.y + y * scale)
|
||||
}
|
||||
|
||||
val earWidth = height * 0.08f
|
||||
val earHeight = height * 0.2f
|
||||
val earOffsetX = height * 0.4f
|
||||
val earOffsetY = 0f
|
||||
val earZ = 0f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
|
||||
val (earX, earY) = project(rotated)
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
|
||||
size = Size(earWidth, earHeight),
|
||||
cornerRadius = CornerRadius(earWidth/2),
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
val spherePath = Path()
|
||||
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
|
||||
spherePath.moveTo(firstPoint.first, firstPoint.second)
|
||||
|
||||
for (i in 1..32) {
|
||||
val angle = (i * 2 * Math.PI / 32).toFloat()
|
||||
val point = project(rotate3D(Triple(
|
||||
cos(angle) * faceRadius,
|
||||
sin(angle) * faceRadius,
|
||||
0f
|
||||
)))
|
||||
spherePath.lineTo(point.first, point.second)
|
||||
}
|
||||
spherePath.close()
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.3f,
|
||||
center.y - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 1.4f,
|
||||
intArrayOf(
|
||||
backgroundColor.copy(alpha = 1f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.95f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.9f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.8f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||
),
|
||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), paint)
|
||||
|
||||
val highlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 0.9f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.argb(100, 255, 255, 255),
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 0.3f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 30 else 60
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||
|
||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 0.7f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 15 else 30
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||
|
||||
val shadowPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.5f,
|
||||
center.y - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 1.1f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.BLACK
|
||||
),
|
||||
floatArrayOf(0.7f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 40 else 20
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), shadowPaint)
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = spherePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
|
||||
val smileRadius = faceRadius * 0.5f
|
||||
val smileStartAngle = -340f
|
||||
val smileSweepAngle = 140f
|
||||
val smileOffsetY = faceRadius * 0.1f
|
||||
|
||||
val smilePath = Path()
|
||||
for (i in 0..32) {
|
||||
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
|
||||
val x = cos(angle.toFloat()) * smileRadius
|
||||
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
|
||||
|
||||
val rotated = rotate3D(Triple(x, y, 0f))
|
||||
val projected = project(rotated)
|
||||
|
||||
if (i == 0) {
|
||||
smilePath.moveTo(projected.first, projected.second)
|
||||
} else {
|
||||
smilePath.lineTo(projected.first, projected.second)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = smilePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
|
||||
val eyeOffsetX = height * 0.15f
|
||||
val eyeOffsetY = height * 0.1f
|
||||
val eyeLength = height * 0.08f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
|
||||
val (eyeX, eyeY) = project(rotated)
|
||||
drawLine(
|
||||
color = strokeColor,
|
||||
start = Offset(eyeX, eyeY - eyeLength/2),
|
||||
end = Offset(eyeX, eyeY + eyeLength/2),
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
typeface = android.graphics.Typeface.create(
|
||||
"SF Pro",
|
||||
android.graphics.Typeface.NORMAL
|
||||
)
|
||||
}
|
||||
|
||||
val pitch = orientation.pitch.toInt()
|
||||
val yaw = orientation.yaw.toInt()
|
||||
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
|
||||
|
||||
drawText(
|
||||
text,
|
||||
width - 8.dp.toPx(),
|
||||
height - 8.dp.toPx(),
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccelerationPlot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
var maxAbs by remember { mutableFloatStateOf(1000f) }
|
||||
|
||||
LaunchedEffect(acceleration) {
|
||||
points.add(Pair(acceleration.horizontal, acceleration.vertical))
|
||||
if (points.size > maxPoints) {
|
||||
points.removeAt(0)
|
||||
}
|
||||
|
||||
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
|
||||
maxAbs = maxOf(currentMax * 1.2f, 1000f)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xScale = width / maxPoints
|
||||
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
|
||||
val zeroY = height / 2
|
||||
|
||||
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
|
||||
|
||||
for (i in 0..maxPoints step 10) {
|
||||
val x = i * xScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
val gridStep = maxAbs / 4
|
||||
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
|
||||
val y = zeroY - value * yScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(width, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
drawLine(
|
||||
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
|
||||
start = Offset(0f, zeroY),
|
||||
end = Offset(width, zeroY),
|
||||
strokeWidth = 1.5f.dp.toPx()
|
||||
)
|
||||
|
||||
if (points.size > 1) {
|
||||
for (i in 0 until points.size - 1) {
|
||||
val x1 = i * xScale
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFF007AFF),
|
||||
start = Offset(x1, zeroY - points[i].first * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].first * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFFFF3B30),
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
}
|
||||
|
||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||
drawText("0", 30.dp.toPx(), height/2, paint)
|
||||
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
|
||||
}
|
||||
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Preview
|
||||
@Composable
|
||||
fun HeadTrackingScreenPreview() {
|
||||
HeadTrackingScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* 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.MoreVert
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Onboarding(navController: NavController, activityContext: Context) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
|
||||
val progressState by radareOffsetFinder.progressState.collectAsState()
|
||||
var isComplete by remember { mutableStateOf(false) }
|
||||
var hasStarted by remember { mutableStateOf(false) }
|
||||
var rootCheckPassed by remember { mutableStateOf(false) }
|
||||
var checkingRoot by remember { mutableStateOf(false) }
|
||||
var rootCheckFailed by remember { mutableStateOf(false) }
|
||||
var moduleEnabled by remember { mutableStateOf(false) }
|
||||
var bluetoothToggled by remember { mutableStateOf(false) }
|
||||
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showSkipDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun checkRootAccess() {
|
||||
checkingRoot = true
|
||||
rootCheckFailed = false
|
||||
kotlinx.coroutines.MainScope().launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec("su -c id")
|
||||
val exitValue = process.waitFor()
|
||||
withContext(Dispatchers.Main) {
|
||||
rootCheckPassed = (exitValue == 0)
|
||||
rootCheckFailed = (exitValue != 0)
|
||||
checkingRoot = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Onboarding", "Root check failed", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
rootCheckPassed = false
|
||||
rootCheckFailed = true
|
||||
checkingRoot = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(hasStarted) {
|
||||
if (hasStarted && rootCheckPassed) {
|
||||
Log.d("Onboarding", "Checking if hook offset is available...")
|
||||
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
|
||||
Log.d("Onboarding", "Hook offset ready: $isHookReady")
|
||||
|
||||
if (isHookReady) {
|
||||
Log.d("Onboarding", "Hook is ready")
|
||||
isComplete = true
|
||||
} else {
|
||||
Log.d("Onboarding", "Hook not ready, starting setup process...")
|
||||
withContext(Dispatchers.IO) {
|
||||
radareOffsetFinder.setupAndFindOffset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(progressState) {
|
||||
if (progressState is RadareOffsetFinder.ProgressState.Success) {
|
||||
isComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Setting Up",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "More Options"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Skip Setup") },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showSkipDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!rootCheckPassed && !hasStarted) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Root Access",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Root Access Required",
|
||||
style = TextStyle(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "This app needs root access to hook onto the Bluetooth library",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
if (rootCheckFailed) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Root access was denied. Please grant root permissions.",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color(0xFFFF453A)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { checkRootAccess() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
enabled = !checkingRoot
|
||||
) {
|
||||
if (checkingRoot) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Check Root Access",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = if (hasStarted) getStatusTitle(progressState, isComplete, moduleEnabled, bluetoothToggled) else "Setup Required",
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
AnimatedContent(
|
||||
targetState = if (hasStarted)
|
||||
getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled)
|
||||
else
|
||||
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (!hasStarted) {
|
||||
Button(
|
||||
onClick = { hasStarted = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Start Setup",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (progressState) {
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
|
||||
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
label = "Download Progress"
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
color = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "${(progress * 100).toInt()}%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
if (!moduleEnabled) {
|
||||
Button(
|
||||
onClick = { moduleEnabled = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"I've Enabled/Reactivated the Module",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (!bluetoothToggled) {
|
||||
Button(
|
||||
onClick = { bluetoothToggled = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"I've Toggled Bluetooth",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
navController.navigate("settings") {
|
||||
popUpTo("onboarding") { inclusive = true }
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue to Settings",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle,
|
||||
is RadareOffsetFinder.ProgressState.Error -> {
|
||||
// No specific UI for these states
|
||||
}
|
||||
else -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
color = accentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
|
||||
Button(
|
||||
onClick = {
|
||||
Log.d("Onboarding", "Trying to find offset again...")
|
||||
kotlinx.coroutines.MainScope().launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
radareOffsetFinder.setupAndFindOffset()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Try Again",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSkipDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSkipDialog = false },
|
||||
title = { Text("Skip Setup") },
|
||||
text = {
|
||||
Text(
|
||||
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
TextButton(
|
||||
onClick = {
|
||||
showSkipDialog = false
|
||||
RadareOffsetFinder.clearHookOffsets()
|
||||
sharedPreferences.edit().putBoolean("skip_setup", true).apply()
|
||||
navController.navigate("settings") {
|
||||
popUpTo("onboarding") { inclusive = true }
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Yes, Skip Setup",
|
||||
color = accentColor,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showSkipDialog = false }
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
containerColor = backgroundColor,
|
||||
textContentColor = textColor,
|
||||
titleContentColor = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusIcon(
|
||||
progressState: RadareOffsetFinder.ProgressState,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
|
||||
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.size(80.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (progressState) {
|
||||
is RadareOffsetFinder.ProgressState.Error -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Error",
|
||||
tint = errorColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Success",
|
||||
tint = successColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(50.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(50.dp),
|
||||
color = accentColor,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusTitle(
|
||||
state: RadareOffsetFinder.ProgressState,
|
||||
isComplete: Boolean,
|
||||
moduleEnabled: Boolean,
|
||||
bluetoothToggled: Boolean
|
||||
): String {
|
||||
return when (state) {
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
when {
|
||||
!moduleEnabled -> "Enable Xposed Module"
|
||||
!bluetoothToggled -> "Toggle Bluetooth"
|
||||
else -> "Setup Complete"
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
|
||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
|
||||
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
|
||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
|
||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
|
||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
|
||||
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusDescription(
|
||||
state: RadareOffsetFinder.ProgressState,
|
||||
isComplete: Boolean,
|
||||
moduleEnabled: Boolean,
|
||||
bluetoothToggled: Boolean
|
||||
): String {
|
||||
return when (state) {
|
||||
is RadareOffsetFinder.ProgressState.Success -> {
|
||||
when {
|
||||
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
|
||||
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
|
||||
else -> "All set! You can now use your AirPods with enhanced functionality."
|
||||
}
|
||||
}
|
||||
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
|
||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
|
||||
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
|
||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
|
||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
|
||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
|
||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
|
||||
is RadareOffsetFinder.ProgressState.Error -> state.message
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingPreview() {
|
||||
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
|
||||
}
|
||||
|
||||
private suspend fun delay(timeMillis: Long) {
|
||||
kotlinx.coroutines.delay(timeMillis)
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.experimental.and
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable()
|
||||
fun RightDivider() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.5.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 72.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable()
|
||||
fun RightDividerNoIcon() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.5.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(navController: NavController, name: String) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
|
||||
if (modesByte != null) {
|
||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
|
||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
|
||||
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
name,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
|
||||
contentDescription = "Back",
|
||||
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
modifier = Modifier.scale(1.5f)
|
||||
)
|
||||
Text(
|
||||
deviceName?: "AirPods Pro",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues = paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LongPressActionElement(
|
||||
name = "Noise Control",
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
|
||||
},
|
||||
isFirst = true,
|
||||
isLast = false
|
||||
)
|
||||
RightDividerNoIcon()
|
||||
LongPressActionElement(
|
||||
name = "Digital Assistant",
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||
sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
|
||||
},
|
||||
isFirst = false,
|
||||
isLast = true
|
||||
)
|
||||
}
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Text(
|
||||
text = "NOISE CONTROL",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp, bottom = 4.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
val offListeningMode = offListeningModeValue == 1.toByte()
|
||||
LongPressElement(
|
||||
name = "Off",
|
||||
enabled = offListeningMode,
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isFirst = true)
|
||||
if (offListeningMode) RightDivider()
|
||||
LongPressElement(
|
||||
name = "Transparency",
|
||||
resourceId = R.drawable.transparency,
|
||||
isFirst = !offListeningMode)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Adaptive",
|
||||
resourceId = R.drawable.adaptive)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Noise Cancellation",
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isLast = true)
|
||||
}
|
||||
Text(
|
||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
||||
val bit = when (name) {
|
||||
"Off" -> 0x01
|
||||
"Transparency" -> 0x02
|
||||
"Noise Cancellation" -> 0x04
|
||||
"Adaptive" -> 0x08
|
||||
else -> -1
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
|
||||
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt())
|
||||
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
|
||||
|
||||
val isChecked = (byteValue.toInt() and bit) != 0
|
||||
val checked = remember { mutableStateOf(isChecked) }
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val textColor = if (darkMode) Color.White else Color.Black
|
||||
val desc = when (name) {
|
||||
"Off" -> "Turns off noise management"
|
||||
"Noise Cancellation" -> "Blocks out external sounds"
|
||||
"Transparency" -> "Lets in external sounds"
|
||||
"Adaptive" -> "Dynamically adjust external noise"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
|
||||
return count
|
||||
}
|
||||
|
||||
fun valueChanged(value: Boolean = !checked.value) {
|
||||
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
|
||||
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
|
||||
|
||||
if (!value) {
|
||||
val newValue = currentValue and bit.inv()
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
|
||||
|
||||
val modeCount = countEnabledModes(newValue)
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
|
||||
|
||||
if (modeCount < 2) {
|
||||
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
|
||||
return
|
||||
}
|
||||
|
||||
val updatedByte = newValue.toByte()
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
|
||||
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
updatedByte
|
||||
)
|
||||
|
||||
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||
.putInt("long_press_byte", newValue).apply()
|
||||
|
||||
checked.value = false
|
||||
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
|
||||
} else {
|
||||
val newValue = currentValue or bit
|
||||
val updatedByte = newValue.toByte()
|
||||
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
updatedByte
|
||||
)
|
||||
|
||||
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
|
||||
.putInt("long_press_byte", newValue).apply()
|
||||
|
||||
checked.value = true
|
||||
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
|
||||
}
|
||||
}
|
||||
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
if (!enabled) {
|
||||
valueChanged(false)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(72.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
valueChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(resourceId),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = 8.dp)
|
||||
)
|
||||
{
|
||||
Text(
|
||||
name,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
Text (
|
||||
desc,
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
checked = checked.value,
|
||||
onCheckedChange = { valueChanged() },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.scale(1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongPressActionElement(
|
||||
name: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
isFirst: Boolean = false,
|
||||
isLast: Boolean = false
|
||||
) {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp)
|
||||
)
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onClick() },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.scale(1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.screens
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
@@ -64,8 +66,9 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) {
|
||||
@Composable
|
||||
fun RenameScreenPreview() {
|
||||
RenameScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
|
||||
package me.kavishdevar.aln.ui.theme
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@@ -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.ui.theme
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -38,21 +38,11 @@ private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ALNTheme(
|
||||
fun LibrePodsTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
@@ -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.ui.theme
|
||||
package me.kavishdevar.librepods.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||
* This class is responsible for handling the L2CAP socket management,
|
||||
* constructing and parsing packets for communication with AirPods.
|
||||
*/
|
||||
class AACPManager {
|
||||
companion object {
|
||||
private const val TAG = "AACPManager"
|
||||
|
||||
object Opcodes {
|
||||
const val SET_FEATURE_FLAGS: Byte = 0x4d
|
||||
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
|
||||
const val BATTERY_INFO: Byte = 0x04
|
||||
const val CONTROL_COMMAND: Byte = 0x09
|
||||
const val EAR_DETECTION: Byte = 0x06
|
||||
const val CONVERSATION_AWARENESS: Byte = 0x4b
|
||||
const val DEVICE_METADATA: Byte = 0x1d
|
||||
const val RENAME: Byte = 0x1E
|
||||
const val HEADTRACKING: Byte = 0x17
|
||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||
const val STEM_PRESS: Byte = 0x19
|
||||
}
|
||||
|
||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||
|
||||
data class ControlCommandStatus(
|
||||
val identifier: ControlCommandIdentifiers,
|
||||
val value: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ControlCommandStatus
|
||||
|
||||
if (identifier != other.identifier) return false
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result: Int = identifier.hashCode()
|
||||
result = 31 * result + value.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// @Suppress("unused")
|
||||
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||
MIC_MODE(0x01),
|
||||
BUTTON_SEND_MODE(0x05),
|
||||
VOICE_TRIGGER(0x12),
|
||||
SINGLE_CLICK_MODE(0x14),
|
||||
DOUBLE_CLICK_MODE(0x15),
|
||||
CLICK_HOLD_MODE(0x16),
|
||||
DOUBLE_CLICK_INTERVAL(0x17),
|
||||
CLICK_HOLD_INTERVAL(0x18),
|
||||
LISTENING_MODE_CONFIGS(0x1A),
|
||||
ONE_BUD_ANC_MODE(0x1B),
|
||||
CROWN_ROTATION_DIRECTION(0x1C),
|
||||
LISTENING_MODE(0x0D),
|
||||
AUTO_ANSWER_MODE(0x1E),
|
||||
CHIME_VOLUME(0x1F),
|
||||
VOLUME_SWIPE_INTERVAL(0x23),
|
||||
CALL_MANAGEMENT_CONFIG(0x24),
|
||||
VOLUME_SWIPE_MODE(0x25),
|
||||
ADAPTIVE_VOLUME_CONFIG(0x26),
|
||||
SOFTWARE_MUTE_CONFIG(0x27),
|
||||
CONVERSATION_DETECT_CONFIG(0x28),
|
||||
SSL(0x29),
|
||||
HEARING_AID(0x2C),
|
||||
AUTO_ANC_STRENGTH(0x2E),
|
||||
HPS_GAIN_SWIPE(0x2F),
|
||||
HRM_STATE(0x30),
|
||||
IN_CASE_TONE_CONFIG(0x31),
|
||||
SIRI_MULTITONE_CONFIG(0x32),
|
||||
HEARING_ASSIST_CONFIG(0x33),
|
||||
ALLOW_OFF_OPTION(0x34),
|
||||
STEM_CONFIG(0x39);
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||
entries.find { it.value == byte }
|
||||
}
|
||||
}
|
||||
|
||||
enum class ProximityKeyType(val value: Byte) {
|
||||
IRK(0x01),
|
||||
ENC_KEY(0x04);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): ProximityKeyType =
|
||||
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||
}
|
||||
}
|
||||
|
||||
enum class StemPressType(val value: Byte) {
|
||||
SINGLE_PRESS(0x05),
|
||||
DOUBLE_PRESS(0x06),
|
||||
TRIPLE_PRESS(0x07),
|
||||
LONG_PRESS(0x08);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): StemPressType? =
|
||||
entries.find { it.value == byte }
|
||||
}
|
||||
}
|
||||
|
||||
enum class StemPressBudType(val value: Byte) {
|
||||
LEFT(0x01),
|
||||
RIGHT(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): StemPressBudType? =
|
||||
entries.find { it.value == byte }
|
||||
}
|
||||
}
|
||||
}
|
||||
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||
|
||||
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||
return controlCommandStatusList.find { it.identifier == identifier }
|
||||
}
|
||||
|
||||
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
|
||||
val existingStatus = getControlCommandStatus(identifier)
|
||||
if (existingStatus == value) {
|
||||
controlCommandStatusList.remove(existingStatus)
|
||||
}
|
||||
if (existingStatus != null) {
|
||||
controlCommandStatusList.remove(existingStatus)
|
||||
}
|
||||
controlCommandListeners[identifier]?.forEach { listener ->
|
||||
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
|
||||
}
|
||||
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
|
||||
}
|
||||
|
||||
interface PacketCallback {
|
||||
fun onBatteryInfoReceived(batteryInfo: ByteArray)
|
||||
fun onEarDetectionReceived(earDetection: ByteArray)
|
||||
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
|
||||
fun onControlCommandReceived(controlCommand: ByteArray)
|
||||
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
|
||||
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||
fun onUnknownPacketReceived(packet: ByteArray)
|
||||
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||
fun onStemPressReceived(stemPress: ByteArray)
|
||||
}
|
||||
|
||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||
if (data.size != 8) {
|
||||
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
|
||||
}
|
||||
if (data[4] != Opcodes.STEM_PRESS) {
|
||||
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
|
||||
}
|
||||
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
|
||||
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
|
||||
return Pair(type, bud)
|
||||
}
|
||||
|
||||
interface ControlCommandListener {
|
||||
fun onControlCommandReceived(controlCommand: ControlCommand)
|
||||
}
|
||||
|
||||
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
|
||||
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||
}
|
||||
|
||||
private var callback: PacketCallback? = null
|
||||
|
||||
fun setPacketCallback(callback: PacketCallback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
fun createDataPacket(data: ByteArray): ByteArray {
|
||||
return HEADER_BYTES + data
|
||||
}
|
||||
|
||||
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
|
||||
val payload = ByteArray(7)
|
||||
|
||||
System.arraycopy(opcode, 0, payload, 0, 2)
|
||||
payload[2] = identifier
|
||||
|
||||
val dataLength = minOf(data.size, 4)
|
||||
System.arraycopy(data, 0, payload, 3, dataLength)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
fun sendDataPacket(data: ByteArray): Boolean {
|
||||
return sendPacket(createDataPacket(data))
|
||||
}
|
||||
|
||||
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||
val controlPacket = createControlCommandPacket(identifier, value)
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||
value
|
||||
)
|
||||
return sendDataPacket(controlPacket)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||
byteArrayOf(value)
|
||||
)
|
||||
return sendDataPacket(controlPacket)
|
||||
}
|
||||
|
||||
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||
)
|
||||
return sendDataPacket(controlPacket)
|
||||
}
|
||||
|
||||
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
|
||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||
byteArrayOf(value.toByte())
|
||||
)
|
||||
return sendDataPacket(controlPacket)
|
||||
}
|
||||
|
||||
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||
if (data.size < 4) {
|
||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||
}
|
||||
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
|
||||
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
|
||||
}
|
||||
val keyCount = data[6].toInt()
|
||||
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
|
||||
var offset = 7
|
||||
for (i in 0 until keyCount) {
|
||||
Log.d(TAG, "Parsing Proximity Key $i")
|
||||
if (offset + 3 >= data.size) {
|
||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||
}
|
||||
val keyType = data[offset]
|
||||
val keyLength = data[offset + 2].toInt()
|
||||
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
|
||||
offset += 4
|
||||
if (offset + keyLength > data.size) {
|
||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||
}
|
||||
val key = ByteArray(keyLength)
|
||||
System.arraycopy(data, offset, key, 0, keyLength)
|
||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||
offset += keyLength
|
||||
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
fun sendRequestProximityKeys(type: Byte): Boolean {
|
||||
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
|
||||
return sendDataPacket(createRequestProximityKeysPacket(type))
|
||||
}
|
||||
|
||||
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
|
||||
val data = byteArrayOf(type, 0x00)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun receivePacket(packet: ByteArray) {
|
||||
if (!packet.toHexString().startsWith("04000400")) {
|
||||
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
return
|
||||
}
|
||||
if (packet.size < 6) {
|
||||
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
return
|
||||
}
|
||||
|
||||
val opcode = packet[4]
|
||||
|
||||
when (opcode) {
|
||||
Opcodes.BATTERY_INFO -> {
|
||||
callback?.onBatteryInfoReceived(packet)
|
||||
}
|
||||
Opcodes.CONTROL_COMMAND -> {
|
||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
|
||||
controlCommand.value
|
||||
)
|
||||
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||
Log.d(TAG, "Control command list is now: ${
|
||||
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
|
||||
}")
|
||||
|
||||
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||
if (controlCommandIdentifier != null) {
|
||||
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||
listener.onControlCommandReceived(controlCommand)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
|
||||
}
|
||||
|
||||
callback?.onControlCommandReceived(packet)
|
||||
}
|
||||
Opcodes.EAR_DETECTION -> {
|
||||
callback?.onEarDetectionReceived(packet)
|
||||
}
|
||||
Opcodes.CONVERSATION_AWARENESS -> {
|
||||
callback?.onConversationAwarenessReceived(packet)
|
||||
}
|
||||
Opcodes.DEVICE_METADATA -> {
|
||||
callback?.onDeviceMetadataReceived(packet)
|
||||
}
|
||||
Opcodes.HEADTRACKING -> {
|
||||
if (packet.size < 70) {
|
||||
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
return
|
||||
}
|
||||
callback?.onHeadTrackingReceived(packet)
|
||||
}
|
||||
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||
callback?.onProximityKeysReceived(packet)
|
||||
}
|
||||
Opcodes.STEM_PRESS -> {
|
||||
callback?.onStemPressReceived(packet)
|
||||
}
|
||||
else -> {
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNotificationRequest(): Boolean {
|
||||
return sendDataPacket(createRequestNotificationPacket())
|
||||
}
|
||||
|
||||
fun createRequestNotificationPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
|
||||
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun sendSetFeatureFlagsPacket(): Boolean {
|
||||
return sendDataPacket(createSetFeatureFlagsPacket())
|
||||
}
|
||||
|
||||
fun createSetFeatureFlagsPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
|
||||
val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun createHandshakePacket(): ByteArray {
|
||||
return byteArrayOf(
|
||||
0x00, 0x00, 0x04, 0x00,
|
||||
0x01, 0x00, 0x02, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
}
|
||||
|
||||
fun sendStartHeadTracking(): Boolean {
|
||||
return sendDataPacket(createStartHeadTrackingPacket())
|
||||
}
|
||||
|
||||
fun createStartHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun createAlternateStartHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun sendStopHeadTracking(): Boolean {
|
||||
return sendDataPacket(createStopHeadTrackingPacket())
|
||||
}
|
||||
|
||||
fun createStopHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun createAlternateStopHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun sendRename(name: String): Boolean {
|
||||
return sendDataPacket(createRenamePacket(name))
|
||||
}
|
||||
|
||||
fun createRenamePacket(name: String): ByteArray {
|
||||
val nameBytes = name.toByteArray()
|
||||
val size = nameBytes.size
|
||||
val packet = ByteArray(5 + size)
|
||||
packet[0] = Opcodes.RENAME
|
||||
packet[1] = 0x00
|
||||
packet[2] = size.toByte()
|
||||
packet[3] = 0x00
|
||||
System.arraycopy(nameBytes, 0, packet, 4, size)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
|
||||
data class ControlCommand(
|
||||
val identifier: Byte,
|
||||
val value: ByteArray
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ControlCommand
|
||||
|
||||
if (identifier != other.identifier) return false
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result: Int = identifier.toInt()
|
||||
result = 31 * result + value.contentHashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromByteArray(data: ByteArray): ControlCommand {
|
||||
if (data.size < 4) {
|
||||
throw IllegalArgumentException("Data array too short to parse ControlCommand")
|
||||
}
|
||||
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
|
||||
val newData = ByteArray(data.size - 4)
|
||||
System.arraycopy(data, 4, newData, 0, data.size - 4)
|
||||
return fromByteArray(newData)
|
||||
}
|
||||
if (data[0] != Opcodes.CONTROL_COMMAND) {
|
||||
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
|
||||
}
|
||||
val identifier = data[2]
|
||||
|
||||
val value = ByteArray(4)
|
||||
System.arraycopy(data, 3, value, 0, 4)
|
||||
|
||||
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||
return ControlCommand(identifier, trimmedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendStemConfigPacket(
|
||||
singlePressCustomized: Boolean = false,
|
||||
doublePressCustomized: Boolean = false,
|
||||
triplePressCustomized: Boolean = false,
|
||||
longPressCustomized: Boolean = false
|
||||
): Boolean {
|
||||
val value = ((if (singlePressCustomized) 0x01 else 0) or
|
||||
(if (doublePressCustomized) 0x02 else 0) or
|
||||
(if (triplePressCustomized) 0x04 else 0) or
|
||||
(if (longPressCustomized) 0x08 else 0)).toByte()
|
||||
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
||||
return sendControlCommand(
|
||||
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendPacket(packet: ByteArray): Boolean {
|
||||
try {
|
||||
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
|
||||
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||
setControlCommandStatusValue(
|
||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||
controlCommand.value
|
||||
)
|
||||
}
|
||||
|
||||
val socket = BluetoothConnectionManager.getCurrentSocket()
|
||||
if (socket?.isConnected == true) {
|
||||
socket.outputStream?.write(packet)
|
||||
socket.outputStream?.flush()
|
||||
return true
|
||||
} else {
|
||||
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
|
||||
return false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error sending packet: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.BluetoothLeScanner
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class BLEManager(private val context: Context) {
|
||||
|
||||
data class AirPodsStatus(
|
||||
val address: String,
|
||||
val lastSeen: Long = System.currentTimeMillis(),
|
||||
val paired: Boolean = false,
|
||||
val model: String = "Unknown",
|
||||
val leftBattery: Int? = null,
|
||||
val rightBattery: Int? = null,
|
||||
val caseBattery: Int? = null,
|
||||
val isLeftInEar: Boolean = false,
|
||||
val isRightInEar: Boolean = false,
|
||||
val isLeftCharging: Boolean = false,
|
||||
val isRightCharging: Boolean = false,
|
||||
val isCaseCharging: Boolean = false,
|
||||
val lidOpen: Boolean = false,
|
||||
val color: String = "Unknown",
|
||||
val connectionState: String = "Unknown"
|
||||
)
|
||||
|
||||
fun getMostRecentStatus(): AirPodsStatus? {
|
||||
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
|
||||
}
|
||||
|
||||
interface AirPodsStatusListener {
|
||||
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
|
||||
fun onBroadcastFromNewAddress(device: AirPodsStatus)
|
||||
fun onLidStateChanged(lidOpen: Boolean)
|
||||
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
|
||||
fun onBatteryChanged(device: AirPodsStatus)
|
||||
}
|
||||
|
||||
private var mBluetoothLeScanner: BluetoothLeScanner? = null
|
||||
private var mScanCallback: ScanCallback? = null
|
||||
private var airPodsStatusListener: AirPodsStatusListener? = null
|
||||
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
|
||||
private val verifiedAddresses = mutableSetOf<String>()
|
||||
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
private var currentGlobalLidState: Boolean? = null
|
||||
private var lastBroadcastTime: Long = 0
|
||||
private val processedAddresses = mutableSetOf<String>()
|
||||
|
||||
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
|
||||
private val modelNames = mapOf(
|
||||
0x0E20 to "AirPods Pro",
|
||||
0x1420 to "AirPods Pro 2",
|
||||
0x2420 to "AirPods Pro 2 (USB-C)",
|
||||
0x0220 to "AirPods 1",
|
||||
0x0F20 to "AirPods 2",
|
||||
0x1320 to "AirPods 3",
|
||||
0x1920 to "AirPods 4",
|
||||
0x1B20 to "AirPods 4 (ANC)",
|
||||
0x0A20 to "AirPods Max",
|
||||
0x1F20 to "AirPods Max (USB-C)"
|
||||
)
|
||||
|
||||
val colorNames = mapOf(
|
||||
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
|
||||
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
|
||||
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
|
||||
0x0B to "Light Blue", 0x0C to "Yellow"
|
||||
)
|
||||
|
||||
val connStates = mapOf(
|
||||
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
|
||||
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
|
||||
)
|
||||
|
||||
private val cleanupHandler = Handler(Looper.getMainLooper())
|
||||
private val cleanupRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
cleanupStaleDevices()
|
||||
checkLidStateTimeout()
|
||||
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
|
||||
airPodsStatusListener = listener
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScanning() {
|
||||
try {
|
||||
Log.d(TAG, "Starting BLE scanner")
|
||||
|
||||
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
val btAdapter = btManager.adapter
|
||||
|
||||
if (btAdapter == null) {
|
||||
Log.d(TAG, "No Bluetooth adapter available")
|
||||
return
|
||||
}
|
||||
|
||||
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||
mScanCallback = null
|
||||
}
|
||||
|
||||
if (!btAdapter.isEnabled) {
|
||||
Log.d(TAG, "Bluetooth is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
|
||||
|
||||
val scanSettings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
.setReportDelay(500L)
|
||||
.build()
|
||||
|
||||
val manufacturerData = ByteArray(27)
|
||||
val manufacturerDataMask = ByteArray(27)
|
||||
|
||||
manufacturerData[0] = 7
|
||||
manufacturerData[1] = 25
|
||||
|
||||
manufacturerDataMask[0] = -1
|
||||
manufacturerDataMask[1] = -1
|
||||
|
||||
val scanFilter = ScanFilter.Builder()
|
||||
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
|
||||
.build()
|
||||
|
||||
mScanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
processScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: List<ScanResult>) {
|
||||
processedAddresses.clear()
|
||||
for (result in results) {
|
||||
processScanResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "BLE scan failed with error code: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
|
||||
Log.d(TAG, "BLE scanner started successfully")
|
||||
|
||||
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error starting BLE scanner", t)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScanning() {
|
||||
try {
|
||||
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||
Log.d(TAG, "Stopping BLE scanner")
|
||||
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||
mScanCallback = null
|
||||
}
|
||||
|
||||
cleanupHandler.removeCallbacks(cleanupRunnable)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error stopping BLE scanner", t)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
||||
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||
return if (keyBase64 != null) {
|
||||
try {
|
||||
Base64.decode(keyBase64)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode encryption key", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
|
||||
return try {
|
||||
if (data.size < 16) {
|
||||
return null
|
||||
}
|
||||
|
||||
val block = data.copyOfRange(data.size - 16, data.size)
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey)
|
||||
cipher.doFinal(block)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error decrypting data", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
|
||||
val charging = (byteVal and 0x80) != 0
|
||||
val level = byteVal and 0x7F
|
||||
return Pair(charging, level)
|
||||
}
|
||||
|
||||
private fun processScanResult(result: ScanResult) {
|
||||
try {
|
||||
val scanRecord = result.scanRecord ?: return
|
||||
val address = result.device.address
|
||||
|
||||
if (processedAddresses.contains(address)) {
|
||||
return
|
||||
}
|
||||
|
||||
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||
if (manufacturerData.size <= 20) return
|
||||
|
||||
if (!verifiedAddresses.contains(address)) {
|
||||
val irk = getIrkFromPreferences()
|
||||
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||
return
|
||||
}
|
||||
verifiedAddresses.add(address)
|
||||
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||
}
|
||||
|
||||
processedAddresses.add(address)
|
||||
lastBroadcastTime = System.currentTimeMillis()
|
||||
|
||||
val encryptionKey = getEncryptionKeyFromPreferences()
|
||||
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||
} else {
|
||||
parseProximityMessage(address, manufacturerData)
|
||||
}
|
||||
|
||||
val previousStatus = deviceStatusMap[address]
|
||||
deviceStatusMap[address] = parsedStatus
|
||||
|
||||
airPodsStatusListener?.let { listener ->
|
||||
if (previousStatus == null) {
|
||||
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||
Log.d(TAG, "New AirPods device detected: $address")
|
||||
|
||||
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||
}
|
||||
} else {
|
||||
if (parsedStatus != previousStatus) {
|
||||
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||
}
|
||||
|
||||
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||
val previousGlobalState = currentGlobalLidState
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
|
||||
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||
listener.onEarStateChanged(
|
||||
parsedStatus,
|
||||
parsedStatus.isLeftInEar,
|
||||
parsedStatus.isRightInEar
|
||||
)
|
||||
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||
}
|
||||
|
||||
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||
listener.onBatteryChanged(parsedStatus)
|
||||
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error processing scan result", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||
val paired = data[2].toInt() == 1
|
||||
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||
|
||||
val status = data[5].toInt() and 0xFF
|
||||
val flagsCase = data[7].toInt() and 0xFF
|
||||
val lid = data[8].toInt() and 0xFF
|
||||
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||
|
||||
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||
val xorFactor = primaryLeft xor thisInCase
|
||||
|
||||
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||
|
||||
val isFlipped = !primaryLeft
|
||||
|
||||
val leftByteIndex = if (isFlipped) 2 else 1
|
||||
val rightByteIndex = if (isFlipped) 1 else 2
|
||||
|
||||
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
|
||||
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
|
||||
|
||||
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
|
||||
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
|
||||
|
||||
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
|
||||
lastValidCaseBatteryMap[address]
|
||||
} else {
|
||||
lastValidCaseBatteryMap[address] = rawCaseBattery
|
||||
rawCaseBattery
|
||||
}
|
||||
|
||||
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||
|
||||
return AirPodsStatus(
|
||||
address = address,
|
||||
lastSeen = System.currentTimeMillis(),
|
||||
paired = paired,
|
||||
model = model,
|
||||
leftBattery = leftBattery,
|
||||
rightBattery = rightBattery,
|
||||
caseBattery = caseBattery,
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isLeftCharging = isLeftCharging,
|
||||
isRightCharging = isRightCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
lidOpen = lidOpen,
|
||||
color = color,
|
||||
connectionState = conn
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanupStaleDevices() {
|
||||
val now = System.currentTimeMillis()
|
||||
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||
|
||||
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
||||
|
||||
for (device in staleDevices) {
|
||||
deviceStatusMap.remove(device.key)
|
||||
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkLidStateTimeout() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
|
||||
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
|
||||
currentGlobalLidState = false
|
||||
airPodsStatusListener?.onLidStateChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getIrkFromPreferences(): ByteArray? {
|
||||
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||
return if (irkBase64 != null) {
|
||||
try {
|
||||
Base64.decode(irkBase64)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decode IRK", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
|
||||
val paired = data[2].toInt() == 1
|
||||
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||
|
||||
val status = data[5].toInt() and 0xFF
|
||||
val podsBattery = data[6].toInt() and 0xFF
|
||||
val flagsCase = data[7].toInt() and 0xFF
|
||||
val lid = data[8].toInt() and 0xFF
|
||||
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||
|
||||
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||
val xorFactor = primaryLeft xor thisInCase
|
||||
|
||||
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||
|
||||
val isFlipped = !primaryLeft
|
||||
|
||||
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
|
||||
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
|
||||
|
||||
val caseBattery = flagsCase and 0x0F
|
||||
val flags = (flagsCase shr 4) and 0x0F
|
||||
|
||||
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
|
||||
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
|
||||
val isCaseCharging = (flags and 0x04) != 0
|
||||
|
||||
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||
|
||||
fun decodeBattery(n: Int): Int? = when (n) {
|
||||
in 0x0..0x9 -> n * 10
|
||||
in 0xA..0xE -> 100
|
||||
0xF -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
return AirPodsStatus(
|
||||
address = address,
|
||||
lastSeen = System.currentTimeMillis(),
|
||||
paired = paired,
|
||||
model = model,
|
||||
leftBattery = decodeBattery(leftBatteryNibble),
|
||||
rightBattery = decodeBattery(rightBatteryNibble),
|
||||
caseBattery = decodeBattery(caseBattery),
|
||||
isLeftInEar = isLeftInEar,
|
||||
isRightInEar = isRightInEar,
|
||||
isLeftCharging = isLeftCharging,
|
||||
isRightCharging = isRightCharging,
|
||||
isCaseCharging = isCaseCharging,
|
||||
lidOpen = lidOpen,
|
||||
color = color,
|
||||
connectionState = conn
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AirPodsBLE"
|
||||
private const val CLEANUP_INTERVAL_MS = 30000L
|
||||
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
|
||||
private const val LID_CLOSE_TIMEOUT_MS = 2000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
|
||||
object BluetoothConnectionManager {
|
||||
private const val TAG = "BluetoothConnectionManager"
|
||||
|
||||
private var currentSocket: BluetoothSocket? = null
|
||||
private var currentDevice: BluetoothDevice? = null
|
||||
|
||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||
currentSocket = socket
|
||||
currentDevice = device
|
||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||
}
|
||||
|
||||
fun getCurrentSocket(): BluetoothSocket? {
|
||||
return currentSocket
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Utilities for Bluetooth cryptography operations, particularly for
|
||||
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||
*/
|
||||
object BluetoothCryptography {
|
||||
|
||||
/**
|
||||
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||
*
|
||||
* @param addr The Bluetooth address to verify
|
||||
* @param irk The Identity Resolving Key to use for verification
|
||||
* @return true if the address is verified as an RPA matching the IRK
|
||||
*/
|
||||
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||
val prand = rpa.copyOfRange(3, 6)
|
||||
val hash = rpa.copyOfRange(0, 3)
|
||||
val computedHash = ah(irk, prand)
|
||||
return hash.contentEquals(computedHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param key The key for encryption
|
||||
* @param data The data to encrypt
|
||||
* @return The encrypted data
|
||||
*/
|
||||
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val swappedKey = key.reversedArray()
|
||||
val swappedData = data.reversedArray()
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
return cipher.doFinal(swappedData).reversedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the ah function as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param k The IRK key
|
||||
* @param r The random part of the address
|
||||
* @return The hash part of the address
|
||||
*/
|
||||
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||
val rPadded = ByteArray(16)
|
||||
r.copyInto(rPadded, 0, 0, 3)
|
||||
val encrypted = e(k, rPadded)
|
||||
return encrypted.copyOfRange(0, 3)
|
||||
}
|
||||
}
|
||||
@@ -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,8 +16,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
@@ -35,10 +36,12 @@ import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||
@@ -66,6 +69,7 @@ object CrossDevice {
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private const val PACKET_LOG_KEY = "packet_log"
|
||||
private var earDetectionStatus = listOf(false, false)
|
||||
var disconnectionRequested = false
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun init(context: Context) {
|
||||
@@ -75,7 +79,7 @@ object CrossDevice {
|
||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
|
||||
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||
startAdvertising()
|
||||
// startAdvertising()
|
||||
startServer()
|
||||
initialized = true
|
||||
}
|
||||
@@ -84,15 +88,25 @@ object CrossDevice {
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startServer() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||
if (!bluetoothAdapter.isEnabled) return@launch
|
||||
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||
Log.d("CrossDevice", "Server started")
|
||||
while (serverSocket != null) {
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
serverSocket?.close()
|
||||
break
|
||||
}
|
||||
if (clientSocket != null) {
|
||||
try {
|
||||
clientSocket!!.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val socket = serverSocket!!.accept()
|
||||
handleClientConnection(socket)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} catch (e: IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,8 +125,11 @@ object CrossDevice {
|
||||
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
|
||||
.addServiceUuid(ParcelUuid(uuid))
|
||||
.build()
|
||||
|
||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
try {
|
||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
|
||||
}
|
||||
Log.d("CrossDevice", "BLE Advertising started")
|
||||
}
|
||||
}
|
||||
@@ -134,13 +151,13 @@ object CrossDevice {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
|
||||
} else {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
||||
// Reset state variables
|
||||
isAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReceivedPacket(packet: ByteArray) {
|
||||
Log.d("CrossDevice", "Sending packet to remote device")
|
||||
if (clientSocket == null || clientSocket!!.outputStream != null) {
|
||||
Log.d("CrossDevice", "Client socket is null")
|
||||
return
|
||||
}
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
||||
@@ -157,20 +174,37 @@ object CrossDevice {
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleClientConnection(socket: BluetoothSocket) {
|
||||
Log.d("CrossDevice", "Client connected")
|
||||
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
clientSocket = socket
|
||||
val inputStream = socket.inputStream
|
||||
val buffer = ByteArray(1024)
|
||||
var bytes: Int
|
||||
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
|
||||
while (true) {
|
||||
bytes = inputStream.read(buffer)
|
||||
val packet = buffer.copyOf(bytes)
|
||||
try {
|
||||
bytes = inputStream.read(buffer)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
val s = serverSocket?.accept()
|
||||
if (s != null) {
|
||||
handleClientConnection(s)
|
||||
}
|
||||
break
|
||||
}
|
||||
var packet = buffer.copyOf(bytes)
|
||||
logPacket(packet, "Relay")
|
||||
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
|
||||
if (bytes == -1) {
|
||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
break
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||
ServiceManager.getService()?.disconnect()
|
||||
disconnectionRequested = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(1000)
|
||||
disconnectionRequested = false
|
||||
}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
|
||||
isAvailable = true
|
||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
|
||||
@@ -190,17 +224,23 @@ object CrossDevice {
|
||||
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||
isAvailable = true
|
||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
|
||||
if (packet.size % 2 == 0) {
|
||||
val half = packet.size / 2
|
||||
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
|
||||
Log.d("CrossDevice", "Duplicated packet, trimming")
|
||||
packet = packet.sliceArray(0 until half)
|
||||
}
|
||||
}
|
||||
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
|
||||
Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
|
||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||
ServiceManager.getService()?.sendPacket(packetInHex)
|
||||
// ServiceManager.getService()?.sendPacket(packetInHex)
|
||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||
batteryBytes = trimmedPacket
|
||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
||||
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
|
||||
ServiceManager.getService()?.updateBatteryWidget()
|
||||
ServiceManager.getService()?.updateBattery()
|
||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||
ServiceManager.getService()?.sendBatteryNotification()
|
||||
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
|
||||
@@ -217,7 +257,7 @@ object CrossDevice {
|
||||
)
|
||||
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
|
||||
ServiceManager.getService()?.applicationContext?.sendBroadcast(
|
||||
Intent("me.kavishdevar.aln.cross_device_island")
|
||||
Intent("me.kavishdevar.librepods.cross_device_island")
|
||||
)
|
||||
}
|
||||
earDetectionStatus = newEarDetectionStatus
|
||||
@@ -229,7 +269,6 @@ object CrossDevice {
|
||||
|
||||
fun sendRemotePacket(byteArray: ByteArray) {
|
||||
if (clientSocket == null || clientSocket!!.outputStream == null) {
|
||||
Log.d("CrossDevice", "Client socket is null")
|
||||
return
|
||||
}
|
||||
clientSocket?.outputStream?.write(byteArray)
|
||||
@@ -237,4 +276,13 @@ object CrossDevice {
|
||||
logPacket(byteArray, "Sent")
|
||||
Log.d("CrossDevice", "Sent packet to remote device")
|
||||
}
|
||||
|
||||
fun notifyAirPodsConnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun notifyAirPodsDisconnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class GestureDetector(
|
||||
private val airPodsService: AirPodsService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "GestureDetector"
|
||||
|
||||
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
||||
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
||||
|
||||
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
||||
private const val MIN_REQUIRED_EXTREMES = 3
|
||||
private const val MAX_REQUIRED_EXTREMES = 4
|
||||
|
||||
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
||||
}
|
||||
|
||||
val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
|
||||
|
||||
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private val horizontalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
private val verticalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private var prevHorizontal: Double = 0.0
|
||||
private var prevVertical: Double = 0.0
|
||||
|
||||
private val horizontalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val horizontalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val verticalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
private val verticalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
|
||||
|
||||
private var lastPeakTime: Long = 0
|
||||
private val peakIntervals = Collections.synchronizedList(ArrayList<Double>())
|
||||
|
||||
private val movementSpeedIntervals = Collections.synchronizedList(ArrayList<Long>())
|
||||
|
||||
private val peakThreshold = 400
|
||||
private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY
|
||||
private val rhythmConsistencyThreshold = 0.5
|
||||
|
||||
private var horizontalIncreasing: Boolean? = null
|
||||
private var verticalIncreasing: Boolean? = null
|
||||
|
||||
private val minConfidenceThreshold = 0.7
|
||||
|
||||
private var isRunning = false
|
||||
private var detectionJob: Job? = null
|
||||
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private var significantMotion = false
|
||||
private var lastSignificantMotionTime = 0L
|
||||
|
||||
init {
|
||||
while (horizontalAvgBuffer.size < 3) horizontalAvgBuffer.add(0.0)
|
||||
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
|
||||
}
|
||||
|
||||
fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
|
||||
if (isRunning) return
|
||||
|
||||
Log.d(TAG, "Starting gesture detection...")
|
||||
isRunning = true
|
||||
gestureDetectedCallback = onGestureDetected
|
||||
|
||||
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
|
||||
|
||||
clearData()
|
||||
|
||||
prevHorizontal = 0.0
|
||||
prevVertical = 0.0
|
||||
|
||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
while (isRunning) {
|
||||
delay(50)
|
||||
|
||||
val gesture = detectGestures()
|
||||
if (gesture != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
audio.playConfirmation(gesture)
|
||||
|
||||
gestureDetectedCallback?.invoke(gesture)
|
||||
stopDetection(doNotStop)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun stopDetection(doNotStop: Boolean = false) {
|
||||
if (!isRunning) return
|
||||
|
||||
Log.d(TAG, "Stopping gesture detection")
|
||||
isRunning = false
|
||||
|
||||
if (!doNotStop) airPodsService.stopHeadTracking()
|
||||
|
||||
detectionJob?.cancel()
|
||||
detectionJob = null
|
||||
gestureDetectedCallback = null
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun processHeadOrientation(horizontal: Int, vertical: Int) {
|
||||
if (!isRunning) return
|
||||
|
||||
if (abs(horizontal) > MAX_VALID_ORIENTATION_VALUE || abs(vertical) > MAX_VALID_ORIENTATION_VALUE) {
|
||||
Log.d(TAG, "Ignoring likely calibration data: h=$horizontal, v=$vertical")
|
||||
return
|
||||
}
|
||||
|
||||
val horizontalDelta = horizontal - prevHorizontal
|
||||
val verticalDelta = vertical - prevVertical
|
||||
|
||||
val significantHorizontal = abs(horizontalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
|
||||
val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
|
||||
|
||||
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
audio.playDirectional(isVertical = false, value = horizontalDelta)
|
||||
}
|
||||
significantMotion = true
|
||||
lastSignificantMotionTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta")
|
||||
}
|
||||
else if (significantVertical) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
audio.playDirectional(isVertical = true, value = verticalDelta)
|
||||
}
|
||||
significantMotion = true
|
||||
lastSignificantMotionTime = System.currentTimeMillis()
|
||||
Log.d(TAG, "Significant VERTICAL movement: $verticalDelta")
|
||||
}
|
||||
else if (significantMotion &&
|
||||
(System.currentTimeMillis() - lastSignificantMotionTime) > 300) {
|
||||
significantMotion = false
|
||||
}
|
||||
|
||||
prevHorizontal = horizontal.toDouble()
|
||||
prevVertical = vertical.toDouble()
|
||||
|
||||
val smoothHorizontal = applySmoothing(horizontal.toDouble(), horizontalAvgBuffer)
|
||||
val smoothVertical = applySmoothing(vertical.toDouble(), verticalAvgBuffer)
|
||||
|
||||
synchronized(horizontalBuffer) {
|
||||
horizontalBuffer.add(smoothHorizontal)
|
||||
if (horizontalBuffer.size > 100) horizontalBuffer.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(verticalBuffer) {
|
||||
verticalBuffer.add(smoothVertical)
|
||||
if (verticalBuffer.size > 100) verticalBuffer.removeAt(0)
|
||||
}
|
||||
|
||||
detectPeaksAndTroughs()
|
||||
}
|
||||
|
||||
private fun applySmoothing(newValue: Double, buffer: MutableList<Double>): Double {
|
||||
synchronized(buffer) {
|
||||
buffer.add(newValue)
|
||||
if (buffer.size > 3) buffer.removeAt(0)
|
||||
return buffer.average()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun detectPeaksAndTroughs() {
|
||||
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
||||
|
||||
val hValues = horizontalBuffer.takeLast(4)
|
||||
val vValues = verticalBuffer.takeLast(4)
|
||||
val hVariance = calculateVariance(hValues)
|
||||
val vVariance = calculateVariance(vValues)
|
||||
|
||||
processDirectionChanges(
|
||||
horizontalBuffer,
|
||||
horizontalIncreasing,
|
||||
hVariance,
|
||||
horizontalPeaks,
|
||||
horizontalTroughs
|
||||
)?.let { horizontalIncreasing = it }
|
||||
|
||||
processDirectionChanges(
|
||||
verticalBuffer,
|
||||
verticalIncreasing,
|
||||
vVariance,
|
||||
verticalPeaks,
|
||||
verticalTroughs
|
||||
)?.let { verticalIncreasing = it }
|
||||
}
|
||||
|
||||
private fun processDirectionChanges(
|
||||
buffer: List<Double>,
|
||||
isIncreasing: Boolean?,
|
||||
variance: Double,
|
||||
peaks: MutableList<Triple<Int, Double, Long>>,
|
||||
troughs: MutableList<Triple<Int, Double, Long>>
|
||||
): Boolean? {
|
||||
if (buffer.size < 2) return isIncreasing
|
||||
|
||||
val current = buffer.last()
|
||||
val prev = buffer[buffer.size - 2]
|
||||
var increasing = isIncreasing ?: (current > prev)
|
||||
|
||||
val dynamicThreshold = max(50.0, min(directionChangeThreshold.toDouble(), variance / 3))
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (increasing && current < prev - dynamicThreshold) {
|
||||
if (abs(prev) > peakThreshold) {
|
||||
peaks.add(Triple(buffer.size - 1, prev, now))
|
||||
if (lastPeakTime > 0) {
|
||||
val interval = (now - lastPeakTime) / 1000.0
|
||||
val timeDiff = now - lastPeakTime
|
||||
|
||||
synchronized(peakIntervals) {
|
||||
peakIntervals.add(interval)
|
||||
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(movementSpeedIntervals) {
|
||||
movementSpeedIntervals.add(timeDiff)
|
||||
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
|
||||
}
|
||||
}
|
||||
lastPeakTime = now
|
||||
}
|
||||
increasing = false
|
||||
} else if (!increasing && current > prev + dynamicThreshold) {
|
||||
if (abs(prev) > peakThreshold) {
|
||||
troughs.add(Triple(buffer.size - 1, prev, now))
|
||||
|
||||
if (lastPeakTime > 0) {
|
||||
val interval = (now - lastPeakTime) / 1000.0
|
||||
val timeDiff = now - lastPeakTime
|
||||
|
||||
synchronized(peakIntervals) {
|
||||
peakIntervals.add(interval)
|
||||
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
|
||||
}
|
||||
|
||||
synchronized(movementSpeedIntervals) {
|
||||
movementSpeedIntervals.add(timeDiff)
|
||||
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
|
||||
}
|
||||
}
|
||||
lastPeakTime = now
|
||||
}
|
||||
increasing = true
|
||||
}
|
||||
|
||||
return increasing
|
||||
}
|
||||
|
||||
private fun calculateVariance(values: List<Double>): Double {
|
||||
if (values.size <= 1) return 0.0
|
||||
|
||||
val mean = values.average()
|
||||
val squaredDiffs = values.map { (it - mean) * (it - mean) }
|
||||
return squaredDiffs.average()
|
||||
}
|
||||
|
||||
|
||||
private fun calculateRhythmConsistency(): Double {
|
||||
if (peakIntervals.size < 2) return 0.0
|
||||
|
||||
val meanInterval = peakIntervals.average()
|
||||
if (meanInterval == 0.0) return 0.0
|
||||
|
||||
val variances = peakIntervals.map { (it / meanInterval - 1.0).pow(2) }
|
||||
val consistency = 1.0 - min(1.0, variances.average() / rhythmConsistencyThreshold)
|
||||
return max(0.0, consistency)
|
||||
}
|
||||
|
||||
|
||||
private fun calculateConfidenceScore(extremes: List<Triple<Int, Double, Long>>, isVertical: Boolean): Double {
|
||||
if (extremes.size < getRequiredExtremes()) return 0.0
|
||||
|
||||
val sortedExtremes = extremes.sortedBy { it.first }
|
||||
|
||||
val recent = sortedExtremes.takeLast(getRequiredExtremes())
|
||||
|
||||
val avgAmplitude = recent.map { abs(it.second) }.average()
|
||||
val amplitudeFactor = min(1.0, avgAmplitude / 600)
|
||||
|
||||
val rhythmFactor = calculateRhythmConsistency()
|
||||
|
||||
val signs = recent.map { if (it.second > 0) 1 else -1 }
|
||||
val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] }
|
||||
val alternationFactor = if (alternating) 1.0 else 0.5
|
||||
|
||||
val isolationFactor = if (isVertical) {
|
||||
val vertAmplitude = recent.map { abs(it.second) }.average()
|
||||
val horizVals = horizontalBuffer.takeLast(recent.size * 2)
|
||||
val horizAmplitude = horizVals.map { abs(it) }.average()
|
||||
min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2)
|
||||
} else {
|
||||
val horizAmplitude = recent.map { abs(it.second) }.average()
|
||||
val vertVals = verticalBuffer.takeLast(recent.size * 2)
|
||||
val vertAmplitude = vertVals.map { abs(it) }.average()
|
||||
min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2)
|
||||
}
|
||||
|
||||
return (
|
||||
amplitudeFactor * 0.4 +
|
||||
rhythmFactor * 0.2 +
|
||||
alternationFactor * 0.2 +
|
||||
isolationFactor * 0.2
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRequiredExtremes(): Int {
|
||||
if (movementSpeedIntervals.isEmpty()) return MIN_REQUIRED_EXTREMES
|
||||
|
||||
val avgInterval = movementSpeedIntervals.average()
|
||||
Log.d(TAG, "Average movement interval: $avgInterval ms")
|
||||
|
||||
return if (avgInterval < FAST_MOVEMENT_THRESHOLD) {
|
||||
MAX_REQUIRED_EXTREMES
|
||||
} else {
|
||||
MIN_REQUIRED_EXTREMES
|
||||
}
|
||||
}
|
||||
|
||||
private fun detectGestures(): Boolean? {
|
||||
val requiredExtremes = getRequiredExtremes()
|
||||
Log.d(TAG, "Current required extremes: $requiredExtremes")
|
||||
|
||||
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
|
||||
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
|
||||
|
||||
val confidence = calculateConfidenceScore(allExtremes, isVertical = true)
|
||||
|
||||
Log.d(TAG, "Vertical motion confidence: $confidence (need $minConfidenceThreshold)")
|
||||
|
||||
if (confidence >= minConfidenceThreshold) {
|
||||
Log.d(TAG, "\"Yes\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) {
|
||||
val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first }
|
||||
|
||||
val confidence = calculateConfidenceScore(allExtremes, isVertical = false)
|
||||
|
||||
Log.d(TAG, "Horizontal motion confidence: $confidence (need $minConfidenceThreshold)")
|
||||
|
||||
if (confidence >= minConfidenceThreshold) {
|
||||
Log.d(TAG, "\"No\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun clearData() {
|
||||
horizontalBuffer.clear()
|
||||
verticalBuffer.clear()
|
||||
horizontalPeaks.clear()
|
||||
horizontalTroughs.clear()
|
||||
verticalPeaks.clear()
|
||||
verticalTroughs.clear()
|
||||
peakIntervals.clear()
|
||||
movementSpeedIntervals.clear()
|
||||
horizontalIncreasing = null
|
||||
verticalIncreasing = null
|
||||
lastPeakTime = 0
|
||||
significantMotion = false
|
||||
lastSignificantMotionTime = 0L
|
||||
}
|
||||
|
||||
private fun Double.pow(exponent: Int): Double = this.pow(exponent.toDouble())
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
@file:Suppress("PrivatePropertyName")
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.SoundPool
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
class GestureFeedback(private val context: Context) {
|
||||
|
||||
private val TAG = "GestureFeedback"
|
||||
|
||||
private val soundsLoaded = AtomicBoolean(false)
|
||||
|
||||
private val soundPool = SoundPool.Builder()
|
||||
.setMaxStreams(3)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.setFlags(AudioAttributes.FLAG_LOW_LATENCY or
|
||||
AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
|
||||
private var soundId = 0
|
||||
private var confirmYesId = 0
|
||||
private var confirmNoId = 0
|
||||
|
||||
private var lastHorizontalTime = 0L
|
||||
private var lastLeftTime = 0L
|
||||
private var lastRightTime = 0L
|
||||
|
||||
private var lastVerticalTime = 0L
|
||||
private var lastUpTime = 0L
|
||||
private var lastDownTime = 0L
|
||||
|
||||
private val MIN_TIME_BETWEEN_SOUNDS = 150L
|
||||
private val MIN_TIME_BETWEEN_DIRECTION = 200L
|
||||
|
||||
private var currentHorizontalStreamId = 0
|
||||
private var currentVerticalStreamId = 0
|
||||
|
||||
|
||||
private val LEFT_VOLUME = Pair(1.0f, 0.0f)
|
||||
private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
|
||||
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
|
||||
|
||||
init {
|
||||
soundId = soundPool.load(context, R.raw.blip_no, 1)
|
||||
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
|
||||
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
|
||||
|
||||
soundPool.setOnLoadCompleteListener { _, _, _ ->
|
||||
Log.d(TAG, "Sounds loaded")
|
||||
soundsLoaded.set(true)
|
||||
|
||||
soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun playDirectional(isVertical: Boolean, value: Double) {
|
||||
if (!soundsLoaded.get()) {
|
||||
Log.d(TAG, "Sounds not yet loaded, skipping playback")
|
||||
return
|
||||
}
|
||||
|
||||
val now = SystemClock.uptimeMillis()
|
||||
|
||||
if (isVertical) {
|
||||
val isUp = value > 0
|
||||
|
||||
if (now - lastVerticalTime < MIN_TIME_BETWEEN_SOUNDS) {
|
||||
Log.d(TAG, "Skipping vertical sound due to general vertical debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (isUp && now - lastUpTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping UP sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isUp && now - lastDownTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping DOWN sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentVerticalStreamId > 0) {
|
||||
soundPool.stop(currentVerticalStreamId)
|
||||
}
|
||||
|
||||
val (leftVol, rightVol) = VERTICAL_VOLUME
|
||||
|
||||
currentVerticalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing VERTICAL sound: ${if (isUp) "UP" else "DOWN"} - streamID=$currentVerticalStreamId")
|
||||
|
||||
lastVerticalTime = now
|
||||
if (isUp) {
|
||||
lastUpTime = now
|
||||
} else {
|
||||
lastDownTime = now
|
||||
}
|
||||
} else {
|
||||
if (now - lastHorizontalTime < MIN_TIME_BETWEEN_SOUNDS) {
|
||||
Log.d(TAG, "Skipping horizontal sound due to general horizontal debounce")
|
||||
return
|
||||
}
|
||||
|
||||
val isRight = value > 0
|
||||
|
||||
if (isRight && now - lastRightTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping RIGHT sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isRight && now - lastLeftTime < MIN_TIME_BETWEEN_DIRECTION) {
|
||||
Log.d(TAG, "Skipping LEFT sound due to direction debounce")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentHorizontalStreamId > 0) {
|
||||
soundPool.stop(currentHorizontalStreamId)
|
||||
}
|
||||
|
||||
val (leftVol, rightVol) = if (isRight) RIGHT_VOLUME else LEFT_VOLUME
|
||||
|
||||
currentHorizontalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing HORIZONTAL sound: ${if (isRight) "RIGHT" else "LEFT"} - streamID=$currentHorizontalStreamId")
|
||||
|
||||
lastHorizontalTime = now
|
||||
if (isRight) {
|
||||
lastRightTime = now
|
||||
} else {
|
||||
lastLeftTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun playConfirmation(isYes: Boolean) {
|
||||
if (currentHorizontalStreamId > 0) {
|
||||
soundPool.stop(currentHorizontalStreamId)
|
||||
}
|
||||
if (currentVerticalStreamId > 0) {
|
||||
soundPool.stop(currentVerticalStreamId)
|
||||
}
|
||||
|
||||
val soundId = if (isYes) confirmYesId else confirmNoId
|
||||
if (soundId != 0 && soundsLoaded.get()) {
|
||||
val streamId = soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f)
|
||||
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
|
||||
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
||||
|
||||
object HeadTracking {
|
||||
private val _orientation = MutableStateFlow(Orientation())
|
||||
val orientation = _orientation.asStateFlow()
|
||||
|
||||
private val _acceleration = MutableStateFlow(Acceleration())
|
||||
val acceleration = _acceleration.asStateFlow()
|
||||
|
||||
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
|
||||
private var isCalibrated = false
|
||||
private var o1Neutral = 19000
|
||||
private var o2Neutral = 0
|
||||
private var o3Neutral = 0
|
||||
|
||||
private const val CALIBRATION_SAMPLE_COUNT = 10
|
||||
private const val ORIENTATION_OFFSET = 5500
|
||||
|
||||
fun processPacket(packet: ByteArray) {
|
||||
val o1 = bytesToInt(packet[43], packet[44])
|
||||
val o2 = bytesToInt(packet[45], packet[46])
|
||||
val o3 = bytesToInt(packet[47], packet[48])
|
||||
|
||||
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
|
||||
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
|
||||
|
||||
if (!isCalibrated) {
|
||||
calibrationSamples.add(Triple(o1, o2, o3))
|
||||
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
|
||||
calibrate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val orientation = calculateOrientation(o1, o2, o3)
|
||||
_orientation.value = orientation
|
||||
|
||||
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
|
||||
}
|
||||
|
||||
private fun calibrate() {
|
||||
if (calibrationSamples.size < 3) return
|
||||
|
||||
// Add offset during calibration
|
||||
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
|
||||
isCalibrated = true
|
||||
}
|
||||
|
||||
@Suppress("UnusedVariable")
|
||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||
if (!isCalibrated) return Orientation()
|
||||
|
||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||
|
||||
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
|
||||
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
|
||||
|
||||
return Orientation(pitch, yaw)
|
||||
}
|
||||
|
||||
private fun bytesToInt(b1: Byte, b2: Byte): Int {
|
||||
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
calibrationSamples.clear()
|
||||
isCalibrated = false
|
||||
_orientation.value = Orientation()
|
||||
_acceleration.value = Acceleration()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log.e
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.AnticipateOvershootInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import androidx.core.content.ContextCompat.getString
|
||||
import androidx.dynamicanimation.animation.DynamicAnimation
|
||||
import androidx.dynamicanimation.animation.SpringAnimation
|
||||
import androidx.dynamicanimation.animation.SpringForce
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class IslandType {
|
||||
CONNECTED,
|
||||
TAKING_OVER,
|
||||
MOVED_TO_REMOTE,
|
||||
}
|
||||
|
||||
class IslandWindow(private val context: Context) {
|
||||
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@SuppressLint("InflateParams")
|
||||
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
||||
private var isClosing = false
|
||||
private var params: WindowManager.LayoutParams? = null
|
||||
|
||||
private var initialY = 0f
|
||||
private var initialTouchY = 0f
|
||||
private var lastTouchY = 0f
|
||||
private var velocityTracker: VelocityTracker? = null
|
||||
private var isBeingDragged = false
|
||||
private var autoCloseHandler: Handler? = null
|
||||
private var autoCloseRunnable: Runnable? = null
|
||||
private var initialHeight = 0
|
||||
private var screenHeight = 0
|
||||
private var isDraggingDown = false
|
||||
private var lastMoveTime = 0L
|
||||
private var yMovement = 0f
|
||||
private var dragDistance = 0f
|
||||
|
||||
private var initialConnectedTextY = 0f
|
||||
private var initialDeviceTextY = 0f
|
||||
private var initialBatteryViewY = 0f
|
||||
private var initialVideoViewY = 0f
|
||||
private var initialTextSeparation = 0f
|
||||
|
||||
private val containerView = FrameLayout(context)
|
||||
|
||||
private lateinit var springAnimation: SpringAnimation
|
||||
private val flingAnimator = ValueAnimator()
|
||||
|
||||
private val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||
updateBatteryDisplay(batteryList)
|
||||
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isVisible: Boolean
|
||||
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||
if (batteryList == null || batteryList.isEmpty()) return
|
||||
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
|
||||
val leftLevel = leftBattery?.level ?: 0
|
||||
val rightLevel = rightBattery?.level ?: 0
|
||||
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||
|
||||
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
|
||||
val displayBatteryLevel = when {
|
||||
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
|
||||
leftLevel > 0 -> leftLevel
|
||||
rightLevel > 0 -> rightLevel
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (displayBatteryLevel != null) {
|
||||
batteryText.text = "$displayBatteryLevel%"
|
||||
batteryProgressBar.progress = displayBatteryLevel
|
||||
batteryProgressBar.isIndeterminate = false
|
||||
} else {
|
||||
batteryText.text = "?"
|
||||
batteryProgressBar.progress = 0
|
||||
batteryProgressBar.isIndeterminate = false
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
|
||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||
if (ServiceManager.getService()?.islandOpen == true) return
|
||||
else ServiceManager.getService()?.islandOpen = true
|
||||
|
||||
val displayMetrics = Resources.getSystem().displayMetrics
|
||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||
screenHeight = displayMetrics.heightPixels
|
||||
|
||||
val batteryList = ServiceManager.getService()?.getBattery()
|
||||
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
|
||||
val displayBatteryLevel = if (batteryList != null) {
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
|
||||
when {
|
||||
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
|
||||
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
|
||||
batteryPercentage > 0 -> batteryPercentage
|
||||
else -> null
|
||||
}
|
||||
} else if (batteryPercentage > 0) {
|
||||
batteryPercentage
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (displayBatteryLevel != null) {
|
||||
batteryText.text = "$displayBatteryLevel%"
|
||||
batteryProgressBar.progress = displayBatteryLevel
|
||||
} else {
|
||||
batteryText.text = "?"
|
||||
batteryProgressBar.progress = 0
|
||||
}
|
||||
|
||||
batteryProgressBar.isIndeterminate = false
|
||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||
|
||||
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(batteryReceiver, batteryIntentFilter)
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||
|
||||
containerView.removeAllViews()
|
||||
val containerParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
containerView.addView(islandView, containerParams)
|
||||
|
||||
params = WindowManager.LayoutParams(
|
||||
width,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
|
||||
islandView.visibility = View.VISIBLE
|
||||
containerView.visibility = View.VISIBLE
|
||||
|
||||
containerView.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||
flingAnimator.cancel()
|
||||
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = VelocityTracker.obtain()
|
||||
velocityTracker?.addMovement(event)
|
||||
|
||||
initialY = containerView.translationY
|
||||
initialTouchY = event.rawY
|
||||
lastTouchY = event.rawY
|
||||
initialHeight = islandView.height
|
||||
isBeingDragged = false
|
||||
isDraggingDown = false
|
||||
lastMoveTime = System.currentTimeMillis()
|
||||
dragDistance = 0f
|
||||
|
||||
captureInitialPositions()
|
||||
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
velocityTracker?.addMovement(event)
|
||||
val deltaY = event.rawY - initialTouchY
|
||||
val moveDelta = event.rawY - lastTouchY
|
||||
dragDistance += abs(moveDelta)
|
||||
|
||||
isDraggingDown = moveDelta > 0
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeDelta = currentTime - lastMoveTime
|
||||
if (timeDelta > 0) {
|
||||
yMovement = moveDelta / timeDelta * 10
|
||||
}
|
||||
lastMoveTime = currentTime
|
||||
|
||||
if (abs(deltaY) > 5 || isBeingDragged) {
|
||||
isBeingDragged = true
|
||||
|
||||
// Cancel auto close timer when dragging starts
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
|
||||
|
||||
val dampedDeltaY = if (deltaY > 0) {
|
||||
initialY + (deltaY * 0.6f)
|
||||
} else {
|
||||
initialY + (deltaY * 0.9f)
|
||||
}
|
||||
containerView.translationY = dampedDeltaY
|
||||
|
||||
if (isDraggingDown && deltaY > 0) {
|
||||
val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
|
||||
applyCustomStretchEffect(stretchAmount, deltaY)
|
||||
}
|
||||
}
|
||||
|
||||
lastTouchY = event.rawY
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
velocityTracker?.addMovement(event)
|
||||
velocityTracker?.computeCurrentVelocity(1000)
|
||||
val yVelocity = velocityTracker?.yVelocity ?: 0f
|
||||
|
||||
if (isBeingDragged) {
|
||||
val currentTranslationY = containerView.translationY
|
||||
val significantVelocity = abs(yVelocity) > 800
|
||||
val significantDrag = abs(dragDistance) > 80
|
||||
|
||||
when {
|
||||
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
|
||||
animateDismissWithInertia(yVelocity)
|
||||
}
|
||||
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
|
||||
animateExpandWithStretch(yVelocity)
|
||||
}
|
||||
else -> {
|
||||
springBackWithInertia(yVelocity)
|
||||
}
|
||||
}
|
||||
} else if (dragDistance < 10) {
|
||||
resetAutoCloseTimer()
|
||||
}
|
||||
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
isBeingDragged = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
when (type) {
|
||||
IslandType.CONNECTED -> {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
|
||||
}
|
||||
IslandType.TAKING_OVER -> {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
|
||||
}
|
||||
IslandType.MOVED_TO_REMOTE -> {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
||||
}
|
||||
}
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
||||
videoView.setVideoURI(videoUri)
|
||||
videoView.setOnPreparedListener { mediaPlayer ->
|
||||
mediaPlayer.isLooping = true
|
||||
videoView.start()
|
||||
}
|
||||
|
||||
windowManager.addView(containerView, params)
|
||||
|
||||
islandView.post {
|
||||
initialHeight = islandView.height
|
||||
captureInitialPositions()
|
||||
}
|
||||
|
||||
springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
|
||||
spring = SpringForce(0f)
|
||||
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
|
||||
}
|
||||
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
||||
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
start()
|
||||
}
|
||||
|
||||
resetAutoCloseTimer()
|
||||
}
|
||||
|
||||
private fun captureInitialPositions() {
|
||||
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
|
||||
connectedText.post {
|
||||
initialConnectedTextY = connectedText.y
|
||||
initialDeviceTextY = deviceText.y
|
||||
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
|
||||
|
||||
if (batteryView != null) initialBatteryViewY = batteryView.y
|
||||
initialVideoViewY = videoView.y
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
|
||||
try {
|
||||
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
|
||||
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
|
||||
val newMinHeight = (initialHeight * stretchFactor).toInt()
|
||||
mainLayout.minimumHeight = newMinHeight
|
||||
|
||||
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
|
||||
|
||||
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||
deviceTextParams.topMargin = textMarginIncrease
|
||||
deviceText.layoutParams = deviceTextParams
|
||||
|
||||
val background = mainLayout.background
|
||||
if (background is GradientDrawable) {
|
||||
val cornerRadius = 56f
|
||||
background.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = screenHeight
|
||||
|
||||
val containerParams = containerView.layoutParams
|
||||
containerParams.height = screenHeight
|
||||
containerView.layoutParams = containerParams
|
||||
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetAutoCloseTimer() {
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
autoCloseHandler = Handler(Looper.getMainLooper())
|
||||
autoCloseRunnable = Runnable { close() }
|
||||
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
|
||||
}
|
||||
|
||||
private fun springBackWithInertia(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
springAnimation.setStartVelocity(velocity)
|
||||
|
||||
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
|
||||
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
|
||||
springAnimation.spring = SpringForce(0f)
|
||||
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||
.setStiffness(dynamicStiffness)
|
||||
|
||||
resetStretchEffects(velocity)
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
springAnimation.start()
|
||||
}
|
||||
|
||||
private fun resetStretchEffects(velocity: Float) {
|
||||
try {
|
||||
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
|
||||
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
|
||||
heightAnimator.duration = 300
|
||||
heightAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||
heightAnimator.addUpdateListener { animation ->
|
||||
mainLayout.minimumHeight = animation.animatedValue as Int
|
||||
}
|
||||
|
||||
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
|
||||
textMarginAnimator.duration = 300
|
||||
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||
textMarginAnimator.addUpdateListener { animation ->
|
||||
deviceTextParams.topMargin = animation.animatedValue as Int
|
||||
deviceText.layoutParams = deviceTextParams
|
||||
}
|
||||
|
||||
heightAnimator.start()
|
||||
textMarginAnimator.start()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateDismissWithInertia(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
val baseDistance = -screenHeight
|
||||
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
|
||||
val targetDistance = baseDistance * velocityFactor
|
||||
|
||||
val baseDuration = 400L
|
||||
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
|
||||
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
|
||||
|
||||
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
|
||||
flingAnimator.duration = duration
|
||||
flingAnimator.addUpdateListener { animation ->
|
||||
containerView.translationY = animation.animatedValue as Float
|
||||
|
||||
val progress = animation.animatedFraction
|
||||
containerView.scaleX = 1f - (progress * 0.5f)
|
||||
containerView.scaleY = 1f - (progress * 0.5f)
|
||||
|
||||
containerView.alpha = 1f - progress
|
||||
}
|
||||
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
forceClose()
|
||||
}
|
||||
})
|
||||
|
||||
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
|
||||
flingAnimator.start()
|
||||
}
|
||||
|
||||
private fun animateExpandWithStretch(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
val baseDuration = 600L
|
||||
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
|
||||
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = screenHeight
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
|
||||
containerAnimator.duration = expandDuration
|
||||
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
|
||||
containerAnimator.addUpdateListener { animation ->
|
||||
containerView.translationY = animation.animatedValue as Float
|
||||
}
|
||||
|
||||
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||
stretchAnimator.duration = expandDuration
|
||||
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
|
||||
stretchAnimator.addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
animateCustomStretch(progress, expandDuration)
|
||||
}
|
||||
|
||||
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
|
||||
normalizeAnimator.duration = 300
|
||||
normalizeAnimator.startDelay = expandDuration - 150
|
||||
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
|
||||
normalizeAnimator.addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
containerView.alpha = progress
|
||||
|
||||
if (progress < 0.7f) {
|
||||
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||
}
|
||||
}
|
||||
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
ServiceManager.getService()?.startMainActivity()
|
||||
forceClose()
|
||||
}
|
||||
})
|
||||
|
||||
containerAnimator.start()
|
||||
stretchAnimator.start()
|
||||
normalizeAnimator.start()
|
||||
}
|
||||
|
||||
private fun animateCustomStretch(progress: Float, duration: Long) {
|
||||
try {
|
||||
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
|
||||
val targetHeight = (screenHeight * 0.7f).toInt()
|
||||
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
|
||||
mainLayout.minimumHeight = currentHeight.toInt()
|
||||
|
||||
val mainLayoutParams = mainLayout.layoutParams
|
||||
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
|
||||
mainLayout.layoutParams = mainLayoutParams
|
||||
|
||||
val targetMargin = (400 * progress).toInt()
|
||||
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||
deviceTextParams.topMargin = targetMargin
|
||||
deviceText.layoutParams = deviceTextParams
|
||||
|
||||
val baseTextSize = 24f
|
||||
deviceText.textSize = baseTextSize + (progress * 8f)
|
||||
|
||||
val baseSubTextSize = 16f
|
||||
connectedText.textSize = baseSubTextSize + (progress * 4f)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
|
||||
resetStretchEffects(0f)
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
try {
|
||||
videoView.stopPlayback()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
|
||||
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
cleanupAndRemoveView()
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// Even if animation fails, ensure we cleanup
|
||||
cleanupAndRemoveView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupAndRemoveView() {
|
||||
containerView.visibility = View.GONE
|
||||
try {
|
||||
if (containerView.parent != null) {
|
||||
windowManager.removeView(containerView)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e("IslandWindow", "Error removing view: $e")
|
||||
}
|
||||
isClosing = false
|
||||
// Make sure all animations are canceled
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
}
|
||||
|
||||
fun forceClose() {
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
// Silent catch - receiver might already be unregistered
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
|
||||
// Cancel all ongoing animations
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
// Immediately remove the view without animations
|
||||
cleanupAndRemoveView()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
isClosing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import io.github.libxposed.api.XposedInterface.AfterHookCallback
|
||||
import io.github.libxposed.api.XposedModule
|
||||
import io.github.libxposed.api.XposedModuleInterface
|
||||
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
|
||||
import io.github.libxposed.api.annotations.AfterInvocation
|
||||
import io.github.libxposed.api.annotations.XposedHooker
|
||||
|
||||
private const val TAG = "AirPodsHook"
|
||||
private lateinit var module: KotlinModule
|
||||
|
||||
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) {
|
||||
init {
|
||||
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}")
|
||||
module = this
|
||||
}
|
||||
|
||||
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) {
|
||||
super.onPackageLoaded(param)
|
||||
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
|
||||
|
||||
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
|
||||
Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
|
||||
|
||||
try {
|
||||
if (param.isFirstPackage) {
|
||||
Log.i(TAG, "Loading native library for Bluetooth hook")
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
Log.i(TAG, "Native library loaded successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load native library: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.google.android.settings") {
|
||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||
try {
|
||||
val headerControllerClass = param.classLoader.loadClass(
|
||||
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||
|
||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||
"updateIcon",
|
||||
android.widget.ImageView::class.java,
|
||||
String::class.java)
|
||||
|
||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
||||
|
||||
try {
|
||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
||||
"displayPreference",
|
||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
||||
|
||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.android.settings") {
|
||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||
try {
|
||||
val headerControllerClass = param.classLoader.loadClass(
|
||||
"com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||
|
||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||
"updateIcon",
|
||||
android.widget.ImageView::class.java,
|
||||
String::class.java)
|
||||
|
||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
||||
|
||||
try {
|
||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
||||
"displayPreference",
|
||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
||||
|
||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a"
|
||||
private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference"
|
||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
||||
|
||||
private const val ANC_MODE_OFF = 1
|
||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
||||
private const val ANC_MODE_TRANSPARENCY = 3
|
||||
private const val ANC_MODE_ADAPTIVE = 4
|
||||
|
||||
private var currentAncMode = ANC_MODE_NOISE_CANCELLATION
|
||||
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterDisplayPreference(callback: AfterHookCallback) {
|
||||
try {
|
||||
val controller = callback.thisObject!!
|
||||
val preferenceScreen = callback.args[0]!!
|
||||
|
||||
val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context
|
||||
|
||||
val deviceField = controller.javaClass.getDeclaredField("mCachedDevice")
|
||||
deviceField.isAccessible = true
|
||||
val cachedDevice = deviceField.get(controller) ?: return
|
||||
|
||||
val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice")
|
||||
val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return
|
||||
|
||||
val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids")
|
||||
val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array<ParcelUuid>
|
||||
|
||||
if (uuids != null) {
|
||||
val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID }
|
||||
|
||||
if (isAirPods) {
|
||||
Log.i(TAG, "AirPods device detected in settings, injecting controls")
|
||||
|
||||
val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java)
|
||||
val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY)
|
||||
|
||||
if (existingPref != null) {
|
||||
Log.i(TAG, "LIBREPODS button already exists, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference")
|
||||
val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context)
|
||||
|
||||
val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java)
|
||||
setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY)
|
||||
|
||||
val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java)
|
||||
setTitleMethod.invoke(preference, "Open LibrePods")
|
||||
|
||||
val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java)
|
||||
setSummaryMethod.invoke(preference, "Control AirPods features")
|
||||
|
||||
val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java)
|
||||
setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage)
|
||||
|
||||
val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java)
|
||||
setOrderMethod.invoke(preference, 1000)
|
||||
|
||||
val intent = Intent().apply {
|
||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java)
|
||||
setIntentMethod.invoke(preference, intent)
|
||||
|
||||
val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass)
|
||||
addPreferenceMethod.invoke(preferenceScreen, preference)
|
||||
|
||||
Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class BluetoothIconHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterUpdateIcon(callback: AfterHookCallback) {
|
||||
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
|
||||
try {
|
||||
val imageView = callback.args[0] as ImageView
|
||||
val iconUri = callback.args[1] as String
|
||||
|
||||
val uri = android.net.Uri.parse(iconUri)
|
||||
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
|
||||
Log.i(TAG, "Handling AirPods icon URI: $uri")
|
||||
|
||||
try {
|
||||
val context = imageView.context
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
val packageName = uri.authority
|
||||
val packageContext = context.createPackageContext(
|
||||
packageName,
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resPath = uri.pathSegments
|
||||
if (resPath.size >= 2 && resPath[0] == "drawable") {
|
||||
val resourceName = resPath[1]
|
||||
val resourceId = packageContext.resources.getIdentifier(
|
||||
resourceName, "drawable", packageName
|
||||
)
|
||||
|
||||
if (resourceId != 0) {
|
||||
val drawable = packageContext.resources.getDrawable(
|
||||
resourceId, packageContext.theme
|
||||
)
|
||||
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.alpha = 1.0f
|
||||
|
||||
callback.result = null
|
||||
|
||||
Log.i(TAG, "Successfully loaded icon from resource: $resourceName")
|
||||
} else {
|
||||
Log.e(TAG, "Resource not found: $resourceName")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading resource from URI $uri: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error accessing context: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getApplicationInfo(): ApplicationInfo {
|
||||
return super.applicationInfo
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANC_MODE_OFF = 1
|
||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
||||
private const val ANC_MODE_TRANSPARENCY = 3
|
||||
private const val ANC_MODE_ADAPTIVE = 4
|
||||
|
||||
private var currentANCMode = ANC_MODE_NOISE_CANCELLATION
|
||||
|
||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
||||
private const val ANIMATION_DURATION = 250L
|
||||
|
||||
private fun addAirPodsControlsToDialog(volumeDialog: Any) {
|
||||
try {
|
||||
val contextField = volumeDialog.javaClass.getDeclaredField("mContext")
|
||||
contextField.isAccessible = true
|
||||
val context = contextField.get(volumeDialog) as Context
|
||||
|
||||
val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView")
|
||||
dialogViewField.isAccessible = true
|
||||
val dialogView = dialogViewField.get(volumeDialog) as ViewGroup
|
||||
|
||||
val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView")
|
||||
dialogRowsViewField.isAccessible = true
|
||||
val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup
|
||||
|
||||
Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}")
|
||||
|
||||
val existingContainer = dialogView.findViewWithTag<View>("airpods_container")
|
||||
if (existingContainer != null) {
|
||||
Log.d(TAG, "AirPods container already exists, ensuring visibility state")
|
||||
val drawer = existingContainer.findViewWithTag<View>("airpods_drawer_container")
|
||||
drawer?.visibility = View.GONE
|
||||
drawer?.alpha = 0f
|
||||
drawer?.translationY = 0f
|
||||
val button = existingContainer.findViewWithTag<ImageButton>("airpods_button")
|
||||
button?.visibility = View.VISIBLE
|
||||
button?.alpha = 1f
|
||||
if (button != null) {
|
||||
updateMainButtonIcon(context, button, currentANCMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val newAirPodsButton = ImageButton(context).apply {
|
||||
tag = "airpods_button"
|
||||
|
||||
try {
|
||||
val airPodsPackage = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
val airPodsIconRes = airPodsPackage.resources.getIdentifier(
|
||||
"airpods", "drawable", "me.kavishdevar.librepods")
|
||||
|
||||
if (airPodsIconRes != 0) {
|
||||
val airPodsDrawable = airPodsPackage.resources.getDrawable(
|
||||
airPodsIconRes, airPodsPackage.theme)
|
||||
setImageDrawable(airPodsDrawable)
|
||||
} else {
|
||||
setImageResource(android.R.drawable.ic_media_play)
|
||||
Log.d(TAG, "Using fallback icon because airpods icon resource not found")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
setImageResource(android.R.drawable.ic_media_play)
|
||||
Log.e(TAG, "Failed to load AirPods icon: ${e.message}")
|
||||
}
|
||||
|
||||
val shape = GradientDrawable()
|
||||
shape.shape = GradientDrawable.RECTANGLE
|
||||
shape.setColor(Color.BLACK)
|
||||
background = shape
|
||||
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
|
||||
setPadding(24, 24, 24, 24)
|
||||
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
90
|
||||
)
|
||||
params.gravity = Gravity.CENTER
|
||||
params.setMargins(0, 0, 0, 0)
|
||||
layoutParams = params
|
||||
|
||||
setOnClickListener {
|
||||
Log.d(TAG, "AirPods button clicked, toggling drawer")
|
||||
val container = findAirPodsContainer(this)
|
||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
||||
if (drawerContainer != null && container != null) {
|
||||
if (drawerContainer.visibility == View.VISIBLE) {
|
||||
hideAirPodsDrawer(container, this, drawerContainer)
|
||||
} else {
|
||||
showAirPodsDrawer(container, this, drawerContainer)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Could not find container or drawer for toggle")
|
||||
}
|
||||
}
|
||||
|
||||
contentDescription = "AirPods Settings"
|
||||
}
|
||||
|
||||
val airPodsContainer = FrameLayout(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
tag = "airpods_container"
|
||||
}
|
||||
|
||||
newAirPodsButton.setOnLongClickListener {
|
||||
Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity")
|
||||
val intent = Intent().apply {
|
||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
context.startActivity(intent)
|
||||
try {
|
||||
val dismissMethod = volumeDialog.javaClass.getMethod("dismissH")
|
||||
dismissMethod.invoke(volumeDialog)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not dismiss volume dialog: ${e.message}")
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
val airPodsDrawer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
tag = "airpods_drawer_container"
|
||||
visibility = View.GONE
|
||||
alpha = 0f
|
||||
|
||||
val drawerShape = GradientDrawable()
|
||||
drawerShape.shape = GradientDrawable.RECTANGLE
|
||||
drawerShape.setColor(Color.BLACK)
|
||||
background = drawerShape
|
||||
|
||||
setPadding(16, 8, 16, 8)
|
||||
}
|
||||
|
||||
val buttonContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
tag = "airpods_button_container"
|
||||
}
|
||||
|
||||
val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION)
|
||||
for (mode in modes) {
|
||||
val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton)
|
||||
airPodsDrawer.addView(modeOption)
|
||||
}
|
||||
|
||||
buttonContainer.addView(newAirPodsButton)
|
||||
|
||||
airPodsContainer.addView(airPodsDrawer)
|
||||
airPodsContainer.addView(buttonContainer)
|
||||
|
||||
val settingsViewField = try {
|
||||
val field = volumeDialog.javaClass.getDeclaredField("mSettingsView")
|
||||
field.isAccessible = true
|
||||
field.get(volumeDialog) as? View
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get settings view field: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (settingsViewField != null && settingsViewField.parent is ViewGroup) {
|
||||
val settingsParent = settingsViewField.parent as ViewGroup
|
||||
val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField)
|
||||
|
||||
if (settingsIndex >= 0) {
|
||||
settingsParent.addView(airPodsContainer, settingsIndex)
|
||||
Log.i(TAG, "Added AirPods controls before settings button")
|
||||
} else {
|
||||
settingsParent.addView(airPodsContainer)
|
||||
Log.i(TAG, "Added AirPods controls to the end of settings parent")
|
||||
}
|
||||
} else {
|
||||
dialogView.addView(airPodsContainer)
|
||||
Log.i(TAG, "Fallback: Added AirPods controls to dialog view")
|
||||
}
|
||||
|
||||
updateMainButtonIcon(context, newAirPodsButton, currentANCMode)
|
||||
|
||||
Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findViewIndexInParent(parent: ViewGroup, view: View): Int {
|
||||
for (i in 0 until parent.childCount) {
|
||||
if (parent.getChildAt(i) == view) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) {
|
||||
try {
|
||||
val pkgContext = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resName = when (mode) {
|
||||
ANC_MODE_OFF -> "noise_cancellation"
|
||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
||||
else -> "noise_cancellation"
|
||||
}
|
||||
|
||||
val resId = pkgContext.resources.getIdentifier(
|
||||
resName, "drawable", "me.kavishdevar.librepods"
|
||||
)
|
||||
|
||||
if (resId != 0) {
|
||||
val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme)
|
||||
button.setImageDrawable(drawable)
|
||||
button.setColorFilter(Color.WHITE)
|
||||
} else {
|
||||
button.setImageResource(getIconResourceForMode(mode))
|
||||
button.setColorFilter(Color.WHITE)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
button.setImageResource(getIconResourceForMode(mode))
|
||||
button.setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
setMargins(0, 6, 0, 6)
|
||||
}
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(24, 16, 24, 16)
|
||||
tag = "anc_mode_${mode}"
|
||||
|
||||
val icon = ImageView(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(60, 60).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
tag = "mode_icon_$mode"
|
||||
|
||||
try {
|
||||
val packageContext = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resourceName = when (mode) {
|
||||
ANC_MODE_OFF -> "noise_cancellation"
|
||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
||||
else -> "noise_cancellation"
|
||||
}
|
||||
|
||||
val resourceId = packageContext.resources.getIdentifier(
|
||||
resourceName, "drawable", "me.kavishdevar.librepods"
|
||||
)
|
||||
|
||||
if (resourceId != 0) {
|
||||
val drawable = packageContext.resources.getDrawable(
|
||||
resourceId, packageContext.theme
|
||||
)
|
||||
setImageDrawable(drawable)
|
||||
} else {
|
||||
setImageResource(getIconResourceForMode(mode))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
setImageResource(getIconResourceForMode(mode))
|
||||
Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}")
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
setColorFilter(Color.BLACK)
|
||||
} else {
|
||||
setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
addView(icon)
|
||||
|
||||
if (isSelected) {
|
||||
background = createSelectedBackground(context)
|
||||
} else {
|
||||
background = null
|
||||
}
|
||||
|
||||
setOnClickListener {
|
||||
Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)")
|
||||
val container = findAirPodsContainer(this)
|
||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
||||
|
||||
if (currentANCMode == mode) {
|
||||
if (drawerContainer != null && container != null) {
|
||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
||||
}
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
currentANCMode = mode
|
||||
|
||||
val parentDrawer = parent as? ViewGroup
|
||||
if (parentDrawer != null) {
|
||||
for (i in 0 until parentDrawer.childCount) {
|
||||
val child = parentDrawer.getChildAt(i) as? LinearLayout
|
||||
if (child != null && child.tag.toString().startsWith("anc_mode_")) {
|
||||
val childModeStr = child.tag.toString().substringAfter("anc_mode_")
|
||||
val childMode = childModeStr.toIntOrNull() ?: -1
|
||||
val childIcon = child.findViewWithTag<ImageView>("mode_icon_${childMode}")
|
||||
|
||||
if (childMode == mode) {
|
||||
child.background = createSelectedBackground(context)
|
||||
childIcon?.setColorFilter(Color.BLACK)
|
||||
} else {
|
||||
child.background = null
|
||||
childIcon?.setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(ACTION_SET_ANC_MODE).apply {
|
||||
setPackage("me.kavishdevar.librepods")
|
||||
putExtra(EXTRA_ANC_MODE, mode)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}")
|
||||
|
||||
|
||||
updateMainButtonIcon(context, mainButton, mode)
|
||||
|
||||
if (drawerContainer != null && container != null) {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSelectedBackground(context: Context): GradientDrawable {
|
||||
return GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
setColor(Color.WHITE)
|
||||
cornerRadius = 50f
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAirPodsContainer(view: View): ViewGroup? {
|
||||
var current: View? = view
|
||||
while (current != null) {
|
||||
if (current is ViewGroup && current.tag == "airpods_container") {
|
||||
return current
|
||||
}
|
||||
val parent = current.parent
|
||||
if (parent is ViewGroup && parent.tag == "airpods_container") {
|
||||
return parent
|
||||
}
|
||||
current = parent as? View
|
||||
}
|
||||
Log.w(TAG, "Could not find airpods_container ancestor")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
||||
Log.d(TAG, "Showing AirPods drawer")
|
||||
val selectedModeView = drawerContainer.findViewWithTag<View>("anc_mode_$currentANCMode")
|
||||
val selectedModeIcon = selectedModeView?.findViewWithTag<ImageView>("mode_icon_$currentANCMode")
|
||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
||||
|
||||
if (selectedModeView == null || selectedModeIcon == null) {
|
||||
Log.e(TAG, "Cannot find selected mode view or icon for show animation")
|
||||
|
||||
drawerContainer.alpha = 0f
|
||||
drawerContainer.visibility = View.VISIBLE
|
||||
|
||||
drawerContainer.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.start()
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(0f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.setStartDelay(ANIMATION_DURATION / 2)
|
||||
?.withEndAction {
|
||||
buttonContainer.visibility = View.GONE
|
||||
}
|
||||
?.start()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
drawerContainer.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
|
||||
val drawerHeight = drawerContainer.measuredHeight
|
||||
|
||||
drawerContainer.alpha = 0f
|
||||
drawerContainer.visibility = View.VISIBLE
|
||||
drawerContainer.translationY = -drawerHeight.toFloat()
|
||||
|
||||
drawerContainer.animate()
|
||||
.translationY(0f)
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(0f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.setStartDelay(ANIMATION_DURATION / 3)
|
||||
?.withEndAction {
|
||||
buttonContainer.visibility = View.GONE
|
||||
}
|
||||
?.start()
|
||||
}
|
||||
|
||||
private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
||||
Log.d(TAG, "Hiding AirPods drawer")
|
||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
||||
|
||||
if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) {
|
||||
buttonContainer.alpha = 0f
|
||||
buttonContainer.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(1f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.start()
|
||||
|
||||
drawerContainer.animate()
|
||||
.translationY(-drawerContainer.height.toFloat())
|
||||
.alpha(0f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.setStartDelay(ANIMATION_DURATION / 4)
|
||||
.withEndAction {
|
||||
drawerContainer.visibility = View.GONE
|
||||
drawerContainer.translationY = 0f
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun getIconResourceForMode(mode: Int): Int {
|
||||
return when (mode) {
|
||||
ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode
|
||||
ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off
|
||||
ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass
|
||||
ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging
|
||||
else -> android.R.drawable.ic_lock_silent_mode_off
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLabelForMode(mode: Int): String {
|
||||
return when (mode) {
|
||||
ANC_MODE_OFF -> "Off"
|
||||
ANC_MODE_TRANSPARENCY -> "Transparency"
|
||||
ANC_MODE_ADAPTIVE -> "Adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.InputStreamReader
|
||||
|
||||
class LogCollector(private val context: Context) {
|
||||
private var isCollecting = false
|
||||
private var logProcess: Process? = null
|
||||
|
||||
suspend fun openXposedSettings(context: Context) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val command = if (android.os.Build.VERSION.SDK_INT >= 29) {
|
||||
"am broadcast -a android.telephony.action.SECRET_CODE -d android_secret_code://5776733 android"
|
||||
} else {
|
||||
"am broadcast -a android.provider.Telephony.SECRET_CODE -d android_secret_code://5776733 android"
|
||||
}
|
||||
|
||||
executeRootCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearLogs() {
|
||||
withContext(Dispatchers.IO) {
|
||||
executeRootCommand("logcat -c")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun killBluetoothService() {
|
||||
withContext(Dispatchers.IO) {
|
||||
executeRootCommand("killall com.android.bluetooth")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPackageUIDs(): Pair<String?, String?> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val btUid = executeRootCommand("dumpsys package com.android.bluetooth | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
Pair(btUid, appUid)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
isCollecting = true
|
||||
val (btUid, appUid) = getPackageUIDs()
|
||||
|
||||
val uidFilter = buildString {
|
||||
if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) {
|
||||
append("$btUid,$appUid")
|
||||
} else if (!btUid.isNullOrEmpty()) {
|
||||
append(btUid)
|
||||
} else if (!appUid.isNullOrEmpty()) {
|
||||
append(appUid)
|
||||
}
|
||||
}
|
||||
|
||||
val command = if (uidFilter.isNotEmpty()) {
|
||||
"su -c logcat --uid=$uidFilter -v threadtime"
|
||||
} else {
|
||||
"su -c logcat -v threadtime"
|
||||
}
|
||||
|
||||
val logs = StringBuilder()
|
||||
try {
|
||||
logProcess = Runtime.getRuntime().exec(command)
|
||||
val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream))
|
||||
var line: String? = null
|
||||
var connectionDetected = false
|
||||
|
||||
while (isCollecting && reader.readLine().also { line = it } != null) {
|
||||
line?.let {
|
||||
if (it.contains("<LogCollector:")) {
|
||||
logs.append("\n=============\n")
|
||||
}
|
||||
|
||||
logs.append(it).append("\n")
|
||||
listener(it)
|
||||
|
||||
if (it.contains("<LogCollector:")) {
|
||||
logs.append("=============\n\n")
|
||||
}
|
||||
|
||||
if (!connectionDetected) {
|
||||
if (it.contains("<LogCollector:Complete:Success>")) {
|
||||
connectionDetected = true
|
||||
connectionDetectedCallback()
|
||||
} else if (it.contains("<LogCollector:Complete:Failed>")) {
|
||||
connectionDetected = true
|
||||
connectionDetectedCallback()
|
||||
} else if (it.contains("<LogCollector:Start>")) {
|
||||
}
|
||||
else if (it.contains("AirPodsService") && it.contains("Connected to device")) {
|
||||
connectionDetected = true
|
||||
connectionDetectedCallback()
|
||||
} else if (it.contains("AirPodsService") && it.contains("Connection failed")) {
|
||||
connectionDetected = true
|
||||
connectionDetectedCallback()
|
||||
} else if (it.contains("AirPodsService") && it.contains("Device disconnected")) {
|
||||
}
|
||||
else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_CONNECTED")) {
|
||||
connectionDetected = true
|
||||
connectionDetectedCallback()
|
||||
} else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_DISCONNECTED")) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logs.append("Error collecting logs: ${e.message}").append("\n")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
logs.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopLogCollection() {
|
||||
isCollecting = false
|
||||
logProcess?.destroy()
|
||||
logProcess = null
|
||||
}
|
||||
|
||||
suspend fun saveLogToInternalStorage(fileName: String, content: String): File? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val logsDir = File(context.filesDir, "logs")
|
||||
if (!logsDir.exists()) {
|
||||
logsDir.mkdir()
|
||||
}
|
||||
|
||||
val file = File(logsDir, fileName)
|
||||
file.writeText(content)
|
||||
return@withContext file
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") {
|
||||
withContext(Dispatchers.IO) {
|
||||
val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
|
||||
.format(java.util.Date())
|
||||
|
||||
val marker = when (markerType) {
|
||||
LogMarkerType.START -> "<LogCollector:Start> [$timestamp] Beginning connection test"
|
||||
LogMarkerType.SUCCESS -> "<LogCollector:Complete:Success> [$timestamp] Connection test completed successfully"
|
||||
LogMarkerType.FAILURE -> "<LogCollector:Complete:Failed> [$timestamp] Connection test failed"
|
||||
LogMarkerType.CUSTOM -> "<LogCollector:Custom:$details> [$timestamp]"
|
||||
}
|
||||
|
||||
val command = "log -t AirPodsService \"$marker\""
|
||||
executeRootCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
enum class LogMarkerType {
|
||||
START,
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
private suspend fun executeRootCommand(command: String): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec("su -c $command")
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val output = StringBuilder()
|
||||
var line: String?
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
output.append(line).append("\n")
|
||||
}
|
||||
|
||||
process.waitFor()
|
||||
output.toString()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,16 +16,21 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioPlaybackConfiguration
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
object MediaController {
|
||||
private var initialVolume: Int? = null
|
||||
@@ -34,11 +39,12 @@ object MediaController {
|
||||
var userPlayedTheMedia = false
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
|
||||
|
||||
var pausedForCrossDevice = false
|
||||
|
||||
private var relativeVolume: Boolean = false
|
||||
private var conversationalAwarenessVolume: Int = 1/12
|
||||
private var conversationalAwarenessVolume: Int = 2
|
||||
private var conversationalAwarenessPauseMusic: Boolean = false
|
||||
|
||||
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
|
||||
@@ -49,16 +55,16 @@ object MediaController {
|
||||
this.sharedPreferences = sharedPreferences
|
||||
Log.d("MediaController", "Initializing MediaController")
|
||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt())
|
||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
|
||||
preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
"relative_conversational_awareness_volume" -> {
|
||||
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
|
||||
}
|
||||
"conversational_awareness_volume" -> {
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt())
|
||||
}
|
||||
"conversational_awareness_pause_music" -> {
|
||||
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
|
||||
@@ -66,36 +72,84 @@ object MediaController {
|
||||
}
|
||||
}
|
||||
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
||||
|
||||
audioManager.registerAudioPlaybackCallback(cb, null)
|
||||
}
|
||||
|
||||
val cb = object : AudioManager.AudioPlaybackCallback() {
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
|
||||
super.onPlaybackConfigChanged(configs)
|
||||
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
|
||||
if (configs != null && !iPausedTheMedia) {
|
||||
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't `play` until the ear detection pauses it.")
|
||||
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
|
||||
handler.postDelayed({
|
||||
iPausedTheMedia = !audioManager.isMusicActive
|
||||
userPlayedTheMedia = audioManager.isMusicActive
|
||||
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
|
||||
}
|
||||
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
|
||||
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
|
||||
if (ServiceManager.getService()?.isConnectedLocally == false) {
|
||||
sendPause(true)
|
||||
pausedForCrossDevice = true
|
||||
}
|
||||
ServiceManager.getService()?.takeOver()
|
||||
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
|
||||
if (!pausedForCrossDevice && audioManager.isMusicActive) {
|
||||
ServiceManager.getService()?.takeOver("music")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getMusicActive(): Boolean {
|
||||
return audioManager.isMusicActive
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPlayPause() {
|
||||
if (audioManager.isMusicActive) {
|
||||
Log.d("MediaController", "Sending pause because music is active")
|
||||
sendPause()
|
||||
} else {
|
||||
Log.d("MediaController", "Sending play because music is not active")
|
||||
sendPlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPreviousTrack() {
|
||||
Log.d("MediaController", "Sending previous track")
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendNextTrack() {
|
||||
Log.d("MediaController", "Sending next track")
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPause(force: Boolean = false) {
|
||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
|
||||
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
|
||||
iPausedTheMedia = true
|
||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||
if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) {
|
||||
iPausedTheMedia = if (force) audioManager.isMusicActive else true
|
||||
userPlayedTheMedia = false
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
@@ -131,15 +185,30 @@ object MediaController {
|
||||
)
|
||||
)
|
||||
}
|
||||
if (!audioManager.isMusicActive) {
|
||||
Log.d("MediaController", "Setting iPausedTheMedia to false")
|
||||
iPausedTheMedia = false
|
||||
}
|
||||
if (pausedForCrossDevice) {
|
||||
Log.d("MediaController", "Setting pausedForCrossDevice to false")
|
||||
pausedForCrossDevice = false
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSpeaking() {
|
||||
Log.d("MediaController", "Starting speaking")
|
||||
Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume")
|
||||
|
||||
if (initialVolume == null) {
|
||||
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
Log.d("MediaController", "Initial Volume Set: $initialVolume")
|
||||
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
|
||||
Log.d("MediaController", "Initial Volume: $initialVolume")
|
||||
val targetVolume = if (relativeVolume) {
|
||||
(initialVolume!! * conversationalAwarenessVolume / 100)
|
||||
} else if (initialVolume!! > (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)) {
|
||||
(audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)
|
||||
} else {
|
||||
initialVolume!!
|
||||
}
|
||||
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
|
||||
if (conversationalAwarenessPauseMusic) {
|
||||
sendPause(force = true)
|
||||
@@ -161,6 +230,7 @@ object MediaController {
|
||||
}
|
||||
|
||||
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
|
||||
Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume")
|
||||
val step = if (fromVolume < toVolume) 1 else -1
|
||||
val delay = 50L
|
||||
var currentVolume = fromVolume
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import kotlin.collections.find
|
||||
|
||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||
class PopupWindow(
|
||||
private val context: Context,
|
||||
private val onCloseCallback: () -> Unit = {}
|
||||
) {
|
||||
private val mView: View
|
||||
private var isClosing = false
|
||||
private var autoCloseHandler = Handler(Looper.getMainLooper())
|
||||
private var autoCloseRunnable: Runnable? = null
|
||||
private var batteryUpdateReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
gravity = Gravity.BOTTOM
|
||||
dimAmount = 0.3f
|
||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
|
||||
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
|
||||
}
|
||||
|
||||
private val mWindowManager: WindowManager
|
||||
|
||||
init {
|
||||
val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
|
||||
mView = layoutInflater.inflate(R.layout.popup_window, null)
|
||||
mParams.x = 0
|
||||
mParams.y = 0
|
||||
|
||||
mParams.gravity = Gravity.BOTTOM
|
||||
mView.setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
|
||||
ll.setOnClickListener {
|
||||
close()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
|
||||
mView.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_DOWN) {
|
||||
val touchY = event.rawY
|
||||
val popupTop = mView.top
|
||||
if (touchY < popupTop) {
|
||||
close()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi", "SetTextI18s")
|
||||
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||
try {
|
||||
if (mView.windowToken == null && mView.parent == null && !isClosing) {
|
||||
mView.findViewById<TextView>(R.id.name).text = name
|
||||
|
||||
updateBatteryStatus(batteryNotification)
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||
vid.start()
|
||||
vid.setOnCompletionListener {
|
||||
vid.start()
|
||||
}
|
||||
|
||||
mWindowManager.addView(mView, mParams)
|
||||
|
||||
val displayMetrics = mView.context.resources.displayMetrics
|
||||
val screenHeight = displayMetrics.heightPixels
|
||||
|
||||
mView.translationY = screenHeight.toFloat()
|
||||
mView.alpha = 1f
|
||||
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
|
||||
|
||||
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
|
||||
duration = 500
|
||||
interpolator = DecelerateInterpolator()
|
||||
start()
|
||||
}
|
||||
|
||||
registerBatteryUpdateReceiver()
|
||||
|
||||
autoCloseRunnable = Runnable { close() }
|
||||
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Error opening popup: ${e.message}")
|
||||
onCloseCallback()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
private fun registerBatteryUpdateReceiver() {
|
||||
batteryUpdateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||
if (batteryList != null) {
|
||||
updateBatteryStatusFromList(batteryList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(batteryUpdateReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterBatteryUpdateReceiver() {
|
||||
batteryUpdateReceiver?.let {
|
||||
try {
|
||||
context.unregisterReceiver(it)
|
||||
batteryUpdateReceiver = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
|
||||
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
||||
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
||||
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
|
||||
|
||||
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"\uDBC3\uDC8E ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"\uDBC3\uDC8D ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"\uDBC3\uDE6C ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18s")
|
||||
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||
val batteryStatus = batteryNotification.getBattery()
|
||||
updateBatteryStatusFromList(batteryStatus)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
|
||||
unregisterBatteryUpdateReceiver()
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.stopPlayback()
|
||||
|
||||
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
|
||||
duration = 500
|
||||
interpolator = AccelerateInterpolator()
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
try {
|
||||
mView.visibility = View.GONE
|
||||
if (mView.parent != null) {
|
||||
mWindowManager.removeView(mView)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Error removing view: ${e.message}")
|
||||
} finally {
|
||||
isClosing = false
|
||||
onCloseCallback()
|
||||
}
|
||||
}
|
||||
})
|
||||
start()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Error closing popup: ${e.message}")
|
||||
isClosing = false
|
||||
onCloseCallback()
|
||||
}
|
||||
}
|
||||
|
||||
val isShowing: Boolean
|
||||
get() = mView.parent != null && !isClosing
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.NoLiveLiterals
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@NoLiveLiterals
|
||||
class RadareOffsetFinder(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "RadareOffsetFinder"
|
||||
private const val RADARE2_URL = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/c9898243c42c0d3d1387de9a37d57ce9df77f9c9_radare2-5.9.9-android-aarch64.tar.gz"
|
||||
private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset"
|
||||
private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset"
|
||||
private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset"
|
||||
private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset"
|
||||
private const val EXTRACT_DIR = "/"
|
||||
|
||||
private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin"
|
||||
private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib"
|
||||
private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox"
|
||||
|
||||
private val LIBRARY_PATHS = listOf(
|
||||
"/apex/com.android.bt/lib64/libbluetooth_jni.so",
|
||||
"/apex/com.android.btservices/lib64/libbluetooth_jni.so",
|
||||
"/system/lib64/libbluetooth_jni.so",
|
||||
"/system/lib64/libbluetooth_qti.so",
|
||||
"/system_ext/lib64/libbluetooth_qti.so"
|
||||
)
|
||||
|
||||
fun findBluetoothLibraryPath(): String? {
|
||||
for (path in LIBRARY_PATHS) {
|
||||
if (File(path).exists()) {
|
||||
Log.d(TAG, "Found Bluetooth library at $path")
|
||||
return path
|
||||
}
|
||||
}
|
||||
Log.e(TAG, "Could not find Bluetooth library")
|
||||
return null
|
||||
}
|
||||
|
||||
fun clearHookOffsets(): Boolean {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf(
|
||||
"su", "-c",
|
||||
"setprop $HOOK_OFFSET_PROP '' && " +
|
||||
"setprop $CFG_REQ_OFFSET_PROP '' && " +
|
||||
"setprop $CSM_CONFIG_OFFSET_PROP '' && " +
|
||||
"setprop $PEER_INFO_REQ_OFFSET_PROP ''"
|
||||
))
|
||||
val exitCode = process.waitFor()
|
||||
|
||||
if (exitCode == 0) {
|
||||
Log.d(TAG, "Successfully cleared hook offset properties")
|
||||
return true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing hook offset properties", e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz")
|
||||
|
||||
private val _progressState = MutableStateFlow<ProgressState>(ProgressState.Idle)
|
||||
val progressState: StateFlow<ProgressState> = _progressState
|
||||
|
||||
sealed class ProgressState {
|
||||
object Idle : ProgressState()
|
||||
object CheckingExisting : ProgressState()
|
||||
object Downloading : ProgressState()
|
||||
data class DownloadProgress(val progress: Float) : ProgressState()
|
||||
object Extracting : ProgressState()
|
||||
object MakingExecutable : ProgressState()
|
||||
object FindingOffset : ProgressState()
|
||||
object SavingOffset : ProgressState()
|
||||
object Cleaning : ProgressState()
|
||||
data class Error(val message: String) : ProgressState()
|
||||
data class Success(val offset: Long) : ProgressState()
|
||||
}
|
||||
|
||||
|
||||
fun isHookOffsetAvailable(): Boolean {
|
||||
Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString())
|
||||
if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) {
|
||||
Log.d(TAG, "Setup skipped, returning true.")
|
||||
return true
|
||||
}
|
||||
_progressState.value = ProgressState.CheckingExisting
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("getprop", HOOK_OFFSET_PROP))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val propValue = reader.readLine()
|
||||
process.waitFor()
|
||||
|
||||
if (propValue != null && propValue.isNotEmpty()) {
|
||||
Log.d(TAG, "Hook offset property exists: $propValue")
|
||||
_progressState.value = ProgressState.Idle
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking if offset property exists", e)
|
||||
_progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "No hook offset available")
|
||||
_progressState.value = ProgressState.Idle
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun setupAndFindOffset(): Boolean {
|
||||
val offset = findOffset()
|
||||
return offset > 0
|
||||
}
|
||||
|
||||
suspend fun findOffset(): Long = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
_progressState.value = ProgressState.Downloading
|
||||
if (!downloadRadare2TarballIfNeeded()) {
|
||||
_progressState.value = ProgressState.Error("Failed to download radare2 tarball")
|
||||
Log.e(TAG, "Failed to download radare2 tarball")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
_progressState.value = ProgressState.Extracting
|
||||
if (!extractRadare2Tarball()) {
|
||||
_progressState.value = ProgressState.Error("Failed to extract radare2 tarball")
|
||||
Log.e(TAG, "Failed to extract radare2 tarball")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
_progressState.value = ProgressState.MakingExecutable
|
||||
if (!makeExecutable()) {
|
||||
_progressState.value = ProgressState.Error("Failed to make binaries executable")
|
||||
Log.e(TAG, "Failed to make binaries executable")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
_progressState.value = ProgressState.FindingOffset
|
||||
val offset = findFunctionOffset()
|
||||
if (offset == 0L) {
|
||||
_progressState.value = ProgressState.Error("Failed to find function offset")
|
||||
Log.e(TAG, "Failed to find function offset")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
_progressState.value = ProgressState.SavingOffset
|
||||
if (!saveOffset(offset)) {
|
||||
_progressState.value = ProgressState.Error("Failed to save offset")
|
||||
Log.e(TAG, "Failed to save offset")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
_progressState.value = ProgressState.Cleaning
|
||||
cleanupExtractedFiles()
|
||||
|
||||
_progressState.value = ProgressState.Success(offset)
|
||||
return@withContext offset
|
||||
|
||||
} catch (e: Exception) {
|
||||
_progressState.value = ProgressState.Error("Error: ${e.message}")
|
||||
Log.e(TAG, "Error in findOffset", e)
|
||||
return@withContext 0L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) {
|
||||
if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) {
|
||||
Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}")
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
try {
|
||||
val url = URL(RADARE2_URL)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 60000
|
||||
connection.readTimeout = 60000
|
||||
|
||||
val contentLength = connection.contentLength.toFloat()
|
||||
val inputStream = connection.inputStream
|
||||
val outputStream = FileOutputStream(radare2TarballFile)
|
||||
|
||||
val buffer = ByteArray(4096)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead = 0L
|
||||
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = totalBytesRead.toFloat() / contentLength
|
||||
_progressState.value = ProgressState.DownloadProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
|
||||
Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}")
|
||||
return@withContext true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to download radare2 tarball", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val isAlreadyExtracted = checkIfAlreadyExtracted()
|
||||
|
||||
if (isAlreadyExtracted) {
|
||||
Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction")
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
Log.d(TAG, "Removing existing extract directory")
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
||||
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
||||
|
||||
Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR")
|
||||
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
|
||||
)
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "Extract output: $line")
|
||||
}
|
||||
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
Log.e(TAG, "Extract error: $line")
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode == 0) {
|
||||
Log.d(TAG, "Extraction completed successfully")
|
||||
return@withContext true
|
||||
} else {
|
||||
Log.e(TAG, "Extraction failed with exit code $exitCode")
|
||||
return@withContext false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to extract radare2", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val checkDirProcess = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
|
||||
)
|
||||
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
|
||||
checkDirProcess.waitFor()
|
||||
|
||||
if (!dirExists) {
|
||||
Log.d(TAG, "Extract directory doesn't exist, need to extract")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val tarProcess = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
|
||||
)
|
||||
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { it.trim() }
|
||||
.toSet()
|
||||
tarProcess.waitFor()
|
||||
|
||||
if (tarFiles.isEmpty()) {
|
||||
Log.e(TAG, "Failed to get file list from tarball")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val findProcess = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
|
||||
)
|
||||
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { it.trim() }
|
||||
.toSet()
|
||||
findProcess.waitFor()
|
||||
|
||||
if (extractedFiles.isEmpty()) {
|
||||
Log.d(TAG, "No files found in extract directory, need to extract")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
for (tarFile in tarFiles) {
|
||||
if (tarFile.endsWith("/")) continue
|
||||
|
||||
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
|
||||
val fileCheckProcess = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
|
||||
)
|
||||
val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists"
|
||||
fileCheckProcess.waitFor()
|
||||
|
||||
if (!fileExists) {
|
||||
Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory")
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory")
|
||||
return@withContext true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking extraction status", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH")
|
||||
val chmod1Result = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH")
|
||||
).waitFor()
|
||||
|
||||
Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH")
|
||||
|
||||
val chmod2Result = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH")
|
||||
).waitFor()
|
||||
|
||||
if (chmod1Result == 0 && chmod2Result == 0) {
|
||||
Log.d(TAG, "Successfully made binaries executable")
|
||||
return@withContext true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result")
|
||||
return@withContext false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error making binaries executable", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) {
|
||||
val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L
|
||||
var offset = 0L
|
||||
|
||||
try {
|
||||
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
|
||||
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
|
||||
val envSetup = """
|
||||
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
|
||||
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
|
||||
""".trimIndent()
|
||||
|
||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan"
|
||||
Log.d(TAG, "Running command: $command")
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var line: String?
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 output: $line")
|
||||
if (line?.contains("fcr_chk_chan") == true) {
|
||||
val parts = line.split(" ")
|
||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
||||
offset = parts[0].substring(2).toLong(16)
|
||||
Log.d(TAG, "Found offset at ${parts[0]}")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 error: $line")
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
||||
}
|
||||
|
||||
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
|
||||
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
|
||||
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to find function offset", e)
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
if (offset == 0L) {
|
||||
Log.e(TAG, "Failed to extract function offset from output, aborting")
|
||||
return@withContext 0L
|
||||
}
|
||||
|
||||
Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}")
|
||||
return@withContext offset
|
||||
}
|
||||
|
||||
private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req"
|
||||
Log.d(TAG, "Running command: $command")
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var line: String?
|
||||
var offset = 0L
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 output: $line")
|
||||
if (line?.contains("l2cu_process_our_cfg_req") == true) {
|
||||
val parts = line.split(" ")
|
||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
||||
offset = parts[0].substring(2).toLong(16)
|
||||
Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 error: $line")
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
||||
}
|
||||
|
||||
if (offset > 0L) {
|
||||
val hexString = "0x${offset.toString(16)}"
|
||||
Runtime.getRuntime().exec(arrayOf(
|
||||
"su", "-c", "setprop $CFG_REQ_OFFSET_PROP $hexString"
|
||||
)).waitFor()
|
||||
Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config"
|
||||
Log.d(TAG, "Running command: $command")
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var line: String?
|
||||
var offset = 0L
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 output: $line")
|
||||
if (line?.contains("l2c_csm_config") == true) {
|
||||
val parts = line.split(" ")
|
||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
||||
offset = parts[0].substring(2).toLong(16)
|
||||
Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 error: $line")
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
||||
}
|
||||
|
||||
if (offset > 0L) {
|
||||
val hexString = "0x${offset.toString(16)}"
|
||||
Runtime.getRuntime().exec(arrayOf(
|
||||
"su", "-c", "setprop $CSM_CONFIG_OFFSET_PROP $hexString"
|
||||
)).waitFor()
|
||||
Log.d(TAG, "Saved l2c_csm_config offset: $hexString")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to find or save l2c_csm_config offset", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req"
|
||||
Log.d(TAG, "Running command: $command")
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
val reader = BufferedReader(InputStreamReader(process.inputStream))
|
||||
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
|
||||
|
||||
var line: String?
|
||||
var offset = 0L
|
||||
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 output: $line")
|
||||
if (line?.contains("l2cu_send_peer_info_req") == true) {
|
||||
val parts = line.split(" ")
|
||||
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
|
||||
offset = parts[0].substring(2).toLong(16)
|
||||
Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (errorReader.readLine().also { line = it } != null) {
|
||||
Log.d(TAG, "rabin2 error: $line")
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
|
||||
}
|
||||
|
||||
if (offset > 0L) {
|
||||
val hexString = "0x${offset.toString(16)}"
|
||||
Runtime.getRuntime().exec(arrayOf(
|
||||
"su", "-c", "setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
|
||||
)).waitFor()
|
||||
Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val hexString = "0x${offset.toString(16)}"
|
||||
Log.d(TAG, "Saving offset to system property: $hexString")
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf(
|
||||
"su", "-c", "setprop $HOOK_OFFSET_PROP $hexString"
|
||||
))
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode == 0) {
|
||||
val verifyProcess = Runtime.getRuntime().exec(arrayOf(
|
||||
"getprop", HOOK_OFFSET_PROP
|
||||
))
|
||||
val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine()
|
||||
verifyProcess.waitFor()
|
||||
|
||||
if (propValue != null && propValue.isNotEmpty()) {
|
||||
Log.d(TAG, "Successfully saved offset to system property: $propValue")
|
||||
return@withContext true
|
||||
} else {
|
||||
Log.e(TAG, "Property was set but couldn't be verified")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to set property, exit code: $exitCode")
|
||||
}
|
||||
return@withContext false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save offset", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupExtractedFiles() {
|
||||
try {
|
||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
|
||||
Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cleanup extracted files", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.util.Log
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
|
||||
object SystemApisUtils {
|
||||
|
||||
/**
|
||||
* Device type which is used in METADATA_DEVICE_TYPE
|
||||
* Indicates this Bluetooth device is an untethered headset.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET: String
|
||||
get() = "Untethered Headset"
|
||||
|
||||
/**
|
||||
* Maximum length of a metadata entry, this is to avoid exploding Bluetooth
|
||||
* disk usage
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MAX_LENGTH: Int
|
||||
get() = 2048
|
||||
|
||||
/**
|
||||
* Manufacturer name of this Bluetooth device
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MANUFACTURER_NAME: Int
|
||||
get() = 0
|
||||
|
||||
/**
|
||||
* Model name of this Bluetooth device
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MODEL_NAME: Int
|
||||
get() = 1
|
||||
|
||||
/**
|
||||
* Software version of this Bluetooth device
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_SOFTWARE_VERSION: Int
|
||||
get() = 2
|
||||
|
||||
/**
|
||||
* Hardware version of this Bluetooth device
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_HARDWARE_VERSION: Int
|
||||
get() = 3
|
||||
|
||||
/**
|
||||
* Package name of the companion app, if any
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_COMPANION_APP: Int
|
||||
get() = 4
|
||||
|
||||
/**
|
||||
* URI to the main icon shown on the settings UI
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MAIN_ICON: Int
|
||||
get() = 5
|
||||
|
||||
/**
|
||||
* Whether this device is an untethered headset with left, right and case
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET: Int
|
||||
get() = 6
|
||||
|
||||
/**
|
||||
* URI to icon of the left headset
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_LEFT_ICON: Int
|
||||
get() = 7
|
||||
|
||||
/**
|
||||
* URI to icon of the right headset
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_ICON: Int
|
||||
get() = 8
|
||||
|
||||
/**
|
||||
* URI to icon of the headset charging case
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_CASE_ICON: Int
|
||||
get() = 9
|
||||
|
||||
/**
|
||||
* Battery level of left headset
|
||||
* Data type should be {@String} 0-100 as [Byte] array, otherwise
|
||||
* as invalid.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_LEFT_BATTERY: Int
|
||||
get() = 10
|
||||
|
||||
/**
|
||||
* Battery level of rigth headset
|
||||
* Data type should be {@String} 0-100 as [Byte] array, otherwise
|
||||
* as invalid.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_BATTERY: Int
|
||||
get() = 11
|
||||
|
||||
/**
|
||||
* Battery level of the headset charging case
|
||||
* Data type should be {@String} 0-100 as [Byte] array, otherwise
|
||||
* as invalid.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY: Int
|
||||
get() = 12
|
||||
|
||||
/**
|
||||
* Whether the left headset is charging
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_LEFT_CHARGING: Int
|
||||
get() = 13
|
||||
|
||||
/**
|
||||
* Whether the right headset is charging
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_CHARGING: Int
|
||||
get() = 14
|
||||
|
||||
/**
|
||||
* Whether the headset charging case is charging
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_CASE_CHARGING: Int
|
||||
get() = 15
|
||||
|
||||
/**
|
||||
* URI to the enhanced settings UI slice
|
||||
* Data type should be {@String} as [Byte] array, null means
|
||||
* the UI does not exist.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI: Int
|
||||
get() = 16
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.COMPANION_TYPE_PRIMARY: String
|
||||
get() = "COMPANION_PRIMARY"
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.COMPANION_TYPE_SECONDARY: String
|
||||
get() = "COMPANION_SECONDARY"
|
||||
|
||||
/**
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.COMPANION_TYPE_NONE: String
|
||||
get() = "COMPANION_NONE"
|
||||
|
||||
/**
|
||||
* Type of the Bluetooth device, must be within the list of
|
||||
* BluetoothDevice.DEVICE_TYPE_*
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_DEVICE_TYPE: Int
|
||||
get() = 17
|
||||
|
||||
/**
|
||||
* Battery level of the Bluetooth device, use when the Bluetooth device
|
||||
* does not support HFP battery indicator.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MAIN_BATTERY: Int
|
||||
get() = 18
|
||||
|
||||
/**
|
||||
* Whether the device is charging.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MAIN_CHARGING: Int
|
||||
get() = 19
|
||||
|
||||
/**
|
||||
* The battery threshold of the Bluetooth device to show low battery icon.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_MAIN_LOW_BATTERY_THRESHOLD: Int
|
||||
get() = 20
|
||||
|
||||
/**
|
||||
* The battery threshold of the left headset to show low battery icon.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD: Int
|
||||
get() = 21
|
||||
|
||||
/**
|
||||
* The battery threshold of the right headset to show low battery icon.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD: Int
|
||||
get() = 22
|
||||
|
||||
/**
|
||||
* The battery threshold of the case to show low battery icon.
|
||||
* Data type should be {@String} as [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD: Int
|
||||
get() = 23
|
||||
|
||||
|
||||
/**
|
||||
* The metadata of the audio spatial data.
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_SPATIAL_AUDIO: Int
|
||||
get() = 24
|
||||
|
||||
/**
|
||||
* The metadata of the Fast Pair for any custmized feature.
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_FAST_PAIR_CUSTOMIZED_FIELDS: Int
|
||||
get() = 25
|
||||
|
||||
/**
|
||||
* The metadata of the Fast Pair for LE Audio capable devices.
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_LE_AUDIO: Int
|
||||
get() = 26
|
||||
|
||||
/**
|
||||
* The UUIDs (16-bit) of registered to CCC characteristics from Media Control services.
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_GMCS_CCCD: Int
|
||||
get() = 27
|
||||
|
||||
/**
|
||||
* The UUIDs (16-bit) of registered to CCC characteristics from Telephony Bearer service.
|
||||
* Data type should be [Byte] array.
|
||||
* @hide
|
||||
*/
|
||||
val BluetoothDevice.METADATA_GTBS_CCCD: Int
|
||||
get() = 28
|
||||
|
||||
const val BATTERY_LEVEL_UNKNOWN: Int = -1
|
||||
|
||||
const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED"
|
||||
const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = "android.intent.extra.show_bluetooth_handsfree_battery"
|
||||
const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = "android.intent.extra.bluetooth_handsfree_battery_level"
|
||||
|
||||
/**
|
||||
* Helper method to set metadata using HiddenApiBypass
|
||||
*/
|
||||
fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean {
|
||||
return try {
|
||||
val result = HiddenApiBypass.invoke(
|
||||
BluetoothDevice::class.java,
|
||||
device,
|
||||
"setMetadata",
|
||||
key,
|
||||
value
|
||||
) as Boolean
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e("SystemApisUtils", "Failed to set metadata for key $key", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,19 +16,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.aln.widgets
|
||||
package me.kavishdevar.librepods.widgets
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import me.kavishdevar.aln.MainActivity
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class BatteryWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
@@ -36,6 +32,6 @@ class BatteryWidget : AppWidgetProvider() {
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
ServiceManager.getService()?.updateBatteryWidget()
|
||||
ServiceManager.getService()?.updateBattery()
|
||||
}
|
||||
}
|
||||
@@ -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,17 +16,21 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.aln.widgets
|
||||
package me.kavishdevar.librepods.widgets
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class NoiseControlWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
@@ -77,7 +81,13 @@ class NoiseControlWidget : AppWidgetProvider() {
|
||||
super.onReceive(context, intent)
|
||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||
ServiceManager.getService()?.setANCMode(mode)
|
||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||
ServiceManager.getService()!!
|
||||
.aacpManager
|
||||
.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
mode.toByte()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/colorBackground"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -4,27 +4,36 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
<group
|
||||
android:translateX="13.5"
|
||||
android:translateY="13.5"
|
||||
android:scaleX="0.75"
|
||||
android:scaleY="0.75">
|
||||
<path
|
||||
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="34.51"
|
||||
android:startY="19.37"
|
||||
android:endX="34.51"
|
||||
android:endY="88.4">
|
||||
<item
|
||||
android:color="#FF64AB5D"
|
||||
android:offset="0"/>
|
||||
<item
|
||||
android:color="#FF21395B"
|
||||
android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:strokeColor="@color/popup_text"
|
||||
android:strokeWidth="0.5"
|
||||
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z"/>
|
||||
<path
|
||||
android:strokeColor="@color/popup_text"
|
||||
android:strokeWidth="2"
|
||||
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<group
|
||||
android:translateX="21.6"
|
||||
android:translateY="21.6"
|
||||
android:scaleX="0.6"
|
||||
android:scaleY="0.6">
|
||||
<group>
|
||||
<path
|
||||
android:strokeColor="@color/popup_text"
|
||||
android:strokeWidth="3.25"
|
||||
android:pathData="M49.64 54.5v33.52c0 0.21 0.17 0.38 0.38 0.38l-5.94-0.52-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 0.09 0.05c1.53 0.87 3.14 1.58 4.8 2.1"/>
|
||||
</group>
|
||||
<path
|
||||
android:strokeColor="@color/popup_text"
|
||||
android:strokeWidth="3.75"
|
||||
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.2 KiB |
9
android/app/src/main/res/drawable/ic_bluetooth.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M440,880v-304L256,760l-56,-56 224,-224 -224,-224 56,-56 184,184v-304h40l228,228 -172,172 172,172L480,880h-40ZM520,384 L596,308 520,234v150ZM520,726 L596,652 520,576v150Z"
|
||||
android:fillColor="#e8eaed"/>
|
||||
</vector>
|
||||
@@ -1,170 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
9
android/app/src/main/res/drawable/ic_layers.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M480,842 L120,562l66,-50 294,228 294,-228 66,50 -360,280ZM480,640L120,360l360,-280 360,280 -360,280ZM480,360ZM480,538 L710,360 480,182 250,360 480,538Z"
|
||||
android:fillColor="#e8eaed"/>
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_save.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" />
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 605 KiB |