Compare commits
146 Commits
v0.0.3-hot
...
v0.1.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
32
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
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,6 +134,56 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## btl2capfix v0.0.3
|
||||
- ([#34](https://github.com/kavishdevar/aln/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
|
||||
- ([#34](https://github.com/kavishdevar/librepods/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
|
||||
|
||||
_[See more here](https://github.com/kavishdevar/aln/releases)_
|
||||
_[See more 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).
|
||||
155
README.md
@@ -1,103 +1,142 @@
|
||||
# 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/)
|
||||
[](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)
|
||||
|
||||
## Tested device(s)
|
||||
- AirPods Pro 2
|
||||
## What is LibrePods?
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## Features
|
||||
## Device Compatibility
|
||||
|
||||
Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list.
|
||||
| Status | Device | Features |
|
||||
|--------|--------|----------|
|
||||
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
|
||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
||||
|
||||
Most features should work with any AirPods. Currently, testing is only performed with AirPods Pro 2.
|
||||
|
||||
## CrossDevice Stuff
|
||||
## Key Features
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This feature is still in development and might not work as expected. No support is provided for this feature.
|
||||
- **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!
|
||||
|
||||
### Features
|
||||
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
|
||||
|
||||
- **Battery Status**: Get battery status on any device when you connect your AirPods to one of them.
|
||||
- **Control AirPods**: Control your AirPods from either of your device when you connect to one, like changing the noise control mode, toggling conversational awareness, and more.
|
||||
- **Automatic Device Switching**: Automatically switch between your Linux and Android device, like when you receive a call, start playing music on Android while you're connected to Linux, and more!
|
||||
## Platform Support
|
||||
|
||||
Check out the demo below!
|
||||
### Linux
|
||||
|
||||
https://raw.githubusercontent.com/kavishdevar/aln/main/android/imgs/cd-demo-2.mp4
|
||||
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
|
||||
|
||||
## Linux — Deprecated, rewrite WIP!
|
||||
- Battery monitoring
|
||||
- Automatic Ear detection
|
||||
- Conversational Awareness
|
||||
- Switching Noise Control modes
|
||||
- Device renaming
|
||||
|
||||
> No support will be provided for the old version of the Linux app. The new version is still in development and might not work as expected. No support is provided for the new version either.
|
||||
> [!NOTE]
|
||||
> Work in progress, but core functionality is stable and usable.
|
||||
|
||||
Check out the README file in [linux](/linux) folder for more info.
|
||||
For installation and detailed info, see the [Linux README](/linux/README.md).
|
||||
|
||||
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.
|
||||
### Android
|
||||
|
||||

|
||||

|
||||
|
||||
## Android
|
||||
|
||||
> Can I use aln without root?
|
||||
|
||||
**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.**
|
||||
|
||||
### Screenshots
|
||||
|
||||
Check out the new animations and transitions in the app!
|
||||
|
||||
https://github.com/user-attachments/assets/470ffc9c-3e52-4dcf-818d-0d1f60986c2e
|
||||
#### 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
|
||||
|
||||
@@ -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 = 6
|
||||
versionName = "0.1.0-rc.3"
|
||||
}
|
||||
|
||||
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,5 @@ 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:sharedUserId="android.uid.system"
|
||||
android:sharedUserMaxSdkVersion="32"
|
||||
tools:targetApi="33">
|
||||
|
||||
<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 +28,12 @@
|
||||
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" />
|
||||
|
||||
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -34,7 +44,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 +75,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,7 +83,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -80,6 +91,15 @@
|
||||
</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 +127,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)
|
||||
417
android/app/src/main/cpp/l2c_fcr_hook.cpp
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* 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([[maybe_unused]] const char *library_path) {
|
||||
if (!hook_func) {
|
||||
LOGE("Hook function not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
uintptr_t base_addr = getModuleBase("libbluetooth_jni.so");
|
||||
if (!base_addr) {
|
||||
LOGE("Failed to get base address of libbluetooth_jni.so");
|
||||
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 library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction(name);
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth 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,289 +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.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
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()
|
||||
|
||||
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 = "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,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,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,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,647 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
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.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
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.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.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.CrossDevice
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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 permissionState = rememberMultiplePermissionsState(
|
||||
permissions = listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE",
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS",
|
||||
)
|
||||
)
|
||||
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 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import kotlin.math.abs
|
||||
|
||||
data class DismissAnimationValues(
|
||||
val offsetY: Dp = 0.dp,
|
||||
val scale: Float = 1f,
|
||||
val alpha: Float = 1f
|
||||
)
|
||||
|
||||
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 }
|
||||
)
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isBound) {
|
||||
unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableDismissBox(
|
||||
onDismiss: () -> Unit,
|
||||
onlyCollapseWhenClicked: () -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
|
||||
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 dragDirection = if (dragOffset > 0) 1f else -1f
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) ?: NoiseControlMode.TRANSPARENCY.ordinal
|
||||
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.setANCMode(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.setCAEnabled(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,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 android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
@@ -45,8 +45,8 @@ 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.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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 android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
@@ -50,7 +50,7 @@ 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.AirPodsService
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -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 android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
@@ -37,8 +37,8 @@ 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.AirPodsService
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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,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 android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -44,12 +44,12 @@ 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.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.Battery
|
||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||
|
||||
@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.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.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.utils.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 { mutableStateOf(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,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 android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -55,7 +55,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
class DropdownItem(val name: String, val onSelect: () -> Unit) {
|
||||
fun select() {
|
||||
@@ -1,22 +1,22 @@
|
||||
/*
|
||||
* 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.SharedPreferences
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -45,10 +45,10 @@ 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
|
||||
|
||||
@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) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
@@ -77,9 +77,10 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
|
||||
.edit()
|
||||
.putBoolean(snakeCasedName, checked)
|
||||
.apply()
|
||||
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, checked)
|
||||
if (functionName != null && service != null) {
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, checked)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -98,8 +99,11 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
|
||||
onCheckedChange = {
|
||||
checked = it
|
||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, it)
|
||||
if (functionName != null && service != null) {
|
||||
val method =
|
||||
service::class.java.getMethod(functionName, Boolean::class.java)
|
||||
method.invoke(service, it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -110,4 +114,4 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
|
||||
@Composable
|
||||
fun IndependentTogglePreview() {
|
||||
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
|
||||
}
|
||||
}
|
||||
@@ -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 android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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,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 android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -72,15 +72,18 @@ 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.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(service: AirPodsService) {
|
||||
fun NoiseControlSettings(
|
||||
service: AirPodsService,
|
||||
onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
|
||||
@@ -113,13 +116,27 @@ 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 // Store previous mode
|
||||
|
||||
// Ensure the mode is valid if 'Off' is disabled
|
||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
|
||||
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
|
||||
} else {
|
||||
noiseControlMode.value = mode
|
||||
mode
|
||||
}
|
||||
if (!received) service.setANCMode(mode.ordinal + 1)
|
||||
when (noiseControlMode.value) {
|
||||
|
||||
noiseControlMode.value = targetMode // Update internal state immediately
|
||||
|
||||
// Only call service if the mode was manually selected (!received)
|
||||
// and the target mode is actually different from the previous mode
|
||||
if (!received && targetMode != previousMode) {
|
||||
service.setANCMode(targetMode.ordinal + 1)
|
||||
// onModeSelectedCallback() // REMOVE this call to keep dialog open
|
||||
}
|
||||
|
||||
// Update divider alphas based on the *new* mode
|
||||
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 1f
|
||||
@@ -312,9 +329,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)
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -429,5 +447,5 @@ fun NoiseControlSettings(service: AirPodsService) {
|
||||
@Preview()
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
NoiseControlSettings(AirPodsService())
|
||||
NoiseControlSettings(AirPodsService()) {}
|
||||
}
|
||||
@@ -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 android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -1,22 +1,22 @@
|
||||
/*
|
||||
* 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 androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -56,7 +56,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
@@ -81,6 +81,8 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -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 android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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,22 +1,22 @@
|
||||
/*
|
||||
* 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.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -50,8 +50,8 @@ 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.AirPodsService
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -95,11 +95,11 @@ 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.setToneVolume(volume = sliderValue.floatValue.toInt())
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -157,4 +157,4 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
||||
@Composable
|
||||
fun ToneVolumeSliderPreview() {
|
||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package me.kavishdevar.aln.composables
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -35,8 +35,8 @@ 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
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -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,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 android.content.SharedPreferences
|
||||
import androidx.compose.foundation.background
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
|
||||
@Composable
|
||||
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
||||
@@ -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,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.receivers
|
||||
package me.kavishdevar.librepods.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import me.kavishdevar.aln.services.AirPodsService
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
|
||||
class BootReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -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.screens
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
@@ -36,11 +36,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.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
|
||||
@@ -78,30 +82,33 @@ 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.haze
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
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.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
|
||||
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.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
@@ -113,6 +120,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
isLocallyConnected = service.isConnectedLocally
|
||||
}
|
||||
|
||||
val nameChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "name") {
|
||||
@@ -144,22 +155,37 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val bluetoothReceiver = remember {
|
||||
|
||||
val connectionReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == "me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(true)
|
||||
when (intent?.action) {
|
||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(true)
|
||||
}
|
||||
}
|
||||
} else if (intent?.action == "me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(false)
|
||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(false)
|
||||
}
|
||||
}
|
||||
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,18 +194,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
|
||||
addAction("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
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(bluetoothReceiver, filter, RECEIVER_EXPORTED)
|
||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(bluetoothReceiver, filter)
|
||||
context.registerReceiver(connectionReceiver, filter)
|
||||
}
|
||||
onDispose {
|
||||
context.unregisterReceiver(bluetoothReceiver)
|
||||
try {
|
||||
context.unregisterReceiver(connectionReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,14 +238,13 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.hazeChild(
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = {
|
||||
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
|
||||
@@ -266,10 +297,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
if (isConnected == true || isRemotelyConnected == true) {
|
||||
if (isLocallyConnected || isRemotelyConnected) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.haze(hazeState)
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(
|
||||
@@ -305,6 +336,21 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
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)
|
||||
|
||||
@@ -372,6 +418,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,7 +448,7 @@ fun AirPodsSettingsScreenPreview() {
|
||||
Column (
|
||||
modifier = Modifier.height(2000.dp)
|
||||
) {
|
||||
ALNTheme (
|
||||
LibrePodsTheme (
|
||||
darkTheme = true
|
||||
) {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
@@ -0,0 +1,763 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.drawBehind
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.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 me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledSwitch
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::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()
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val hazeState = remember { HazeState() }
|
||||
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var showPhoneBatteryInWidget by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
||||
}
|
||||
var conversationalAwarenessPauseMusicEnabled by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
|
||||
}
|
||||
var relativeConversationalAwarenessVolumeEnabled by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
|
||||
}
|
||||
var openDialogForControlling by remember {
|
||||
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
|
||||
}
|
||||
var disconnectWhenNotWearing by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
||||
}
|
||||
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(
|
||||
text = stringResource(R.string.app_settings),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
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(
|
||||
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))
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
|
||||
else Color(0xFFF2F2F7),
|
||||
) { paddingValues ->
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
.hazeSource(state = hazeState)
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Widget".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
showPhoneBatteryInWidget = !showPhoneBatteryInWidget
|
||||
sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", showPhoneBatteryInWidget).apply()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Show phone battery in widget",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Display your phone's battery level in the widget alongside AirPods battery",
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = showPhoneBatteryInWidget,
|
||||
onCheckedChange = {
|
||||
showPhoneBatteryInWidget = it
|
||||
sharedPreferences.edit().putBoolean("show_phone_battery_in_widget", it).apply()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Conversational Awareness".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(sliderValue) {
|
||||
if (sharedPreferences.contains("conversational_awareness_volume")) {
|
||||
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
||||
conversationalAwarenessPauseMusicEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply()
|
||||
}
|
||||
|
||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
||||
relativeConversationalAwarenessVolumeEnabled = enabled
|
||||
sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateConversationalAwarenessPauseMusic(!conversationalAwarenessPauseMusicEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.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()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateRelativeConversationalAwarenessVolume(!relativeConversationalAwarenessVolumeEnabled)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Conversational Awareness Volume",
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
|
||||
Slider(
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
sharedPreferences.edit().putInt("conversational_awareness_volume", it.toInt()).apply()
|
||||
},
|
||||
valueRange = 10f..85f,
|
||||
onValueChangeFinished = {
|
||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.padding(vertical = 4.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()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "10%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = "85%",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.7f)
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Quick Settings Tile".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
fun updateQsClickBehavior(enabled: Boolean) {
|
||||
openDialogForControlling = enabled
|
||||
sharedPreferences.edit().putString("qs_click_behavior", if (enabled) "dialog" else "cycle").apply()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateQsClickBehavior(!openDialogForControlling)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Open dialog for controlling",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (openDialogForControlling)
|
||||
"If disabled, clicking on the QS will cycle through modes"
|
||||
else "If enabled, it will show a dialog for controlling noise control mode and conversational awareness",
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = openDialogForControlling,
|
||||
onCheckedChange = {
|
||||
updateQsClickBehavior(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Ear Detection".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
fun updateDisconnectWhenNotWearing(enabled: Boolean) {
|
||||
disconnectWhenNotWearing = enabled
|
||||
sharedPreferences.edit().putBoolean("disconnect_when_not_wearing", enabled).apply()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
updateDisconnectWhenNotWearing(!disconnectWhenNotWearing)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Disconnect AirPods when not wearing",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "You will still be able to control them with the app - this just disconnects the audio.",
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = disconnectWhenNotWearing,
|
||||
onCheckedChange = {
|
||||
updateDisconnectWhenNotWearing(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Advanced Options".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
navController.navigate("troubleshooting")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { showResetDialog = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reset",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Reset Hook Offset",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = {
|
||||
Text(
|
||||
"Reset Hook Offset",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue?",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (RadareOffsetFinder.clearHookOffsets()) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Hook offset has been reset. Redirecting to setup...",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
navController.navigate("onboarding") {
|
||||
popUpTo("settings") { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to reset hook offset",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
showResetDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Reset",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showResetDialog = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
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.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||
|
||||
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?.sendPacket(packet.value.text)
|
||||
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,856 @@
|
||||
/*
|
||||
* 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.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.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)
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
/*
|
||||
* 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.screens
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -67,8 +67,8 @@ 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
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
|
||||
@Composable()
|
||||
fun RightDivider() {
|
||||
@@ -294,4 +294,4 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.screens
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
@@ -64,8 +64,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.aln.services.ServiceManager
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* 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.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 androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import me.kavishdevar.librepods.MainActivity
|
||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
|
||||
@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 {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
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.setANCMode(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
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
override fun onTileAdded() {
|
||||
super.onTileAdded()
|
||||
Log.d("AirPodsQSService", "Tile added")
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
fun openMainActivity() {
|
||||
Log.d("AirPodsQSService", "Opening MainActivity")
|
||||
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
|
||||
} catch (e: Exception) {
|
||||
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.utils
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
@@ -37,7 +37,7 @@ 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
|
||||
|
||||
@@ -77,7 +77,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
|
||||
}
|
||||
@@ -90,12 +90,21 @@ object CrossDevice {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,8 +123,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")
|
||||
}
|
||||
}
|
||||
@@ -143,9 +155,7 @@ object CrossDevice {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -228,7 +238,7 @@ object CrossDevice {
|
||||
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) {
|
||||
@@ -245,7 +255,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
|
||||
@@ -257,7 +267,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)
|
||||
@@ -267,11 +276,11 @@ object CrossDevice {
|
||||
}
|
||||
|
||||
fun notifyAirPodsConnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun notifyAirPodsDisconnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
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.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 START_CMD = "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"
|
||||
private const val STOP_CMD = "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"
|
||||
|
||||
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
|
||||
|
||||
clearData()
|
||||
|
||||
prevHorizontal = 0.0
|
||||
prevVertical = 0.0
|
||||
|
||||
airPodsService.sendPacket(START_CMD)
|
||||
|
||||
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.sendPacket(STOP_CMD)
|
||||
|
||||
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,212 @@
|
||||
@file:Suppress("PrivatePropertyName")
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
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 fun forceBluetoothRouting(audioManager: AudioManager) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
val bluetoothDevice = devices.find {
|
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
|
||||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
|
||||
}
|
||||
|
||||
bluetoothDevice?.let { device ->
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build())
|
||||
.build()
|
||||
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
|
||||
if (!audioManager.isBluetoothScoOn) {
|
||||
audioManager.isBluetoothScoOn = true
|
||||
audioManager.startBluetoothSco()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Forced audio routing to Bluetooth device")
|
||||
}
|
||||
} else {
|
||||
if (!audioManager.isBluetoothScoOn) {
|
||||
audioManager.isBluetoothScoOn = true
|
||||
audioManager.startBluetoothSco()
|
||||
Log.d(TAG, "Started Bluetooth SCO")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to force Bluetooth routing", e)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
try {
|
||||
soundPool.release()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error releasing resources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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()
|
||||
|
||||
// Add offset before normalizationval
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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.utils
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
@@ -39,13 +39,14 @@ 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
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
|
||||
enum class IslandType {
|
||||
CONNECTED,
|
||||
TAKING_OVER,
|
||||
MOVED_TO_REMOTE
|
||||
MOVED_TO_REMOTE,
|
||||
// CALL_GESTURE
|
||||
}
|
||||
|
||||
class IslandWindow(context: Context) {
|
||||
@@ -84,14 +85,20 @@ class IslandWindow(context: Context) {
|
||||
close()
|
||||
}
|
||||
|
||||
if (type == IslandType.TAKING_OVER) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
|
||||
} else if (type == IslandType.MOVED_TO_REMOTE) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
||||
} else if (CrossDevice.isAvailable) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
|
||||
} else if (type == IslandType.CONNECTED) {
|
||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
|
||||
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)
|
||||
}
|
||||
// IslandType.CALL_GESTURE -> {
|
||||
// islandView.findViewById<TextView>(R.id.island_connected_text).text = "Incoming Call from $name"
|
||||
// islandView.findViewById<TextView>(R.id.island_device_name).text = "Use Head Gestures to answer."
|
||||
// }
|
||||
}
|
||||
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
@@ -99,7 +106,7 @@ class IslandWindow(context: Context) {
|
||||
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}")
|
||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
||||
videoView.setVideoURI(videoUri)
|
||||
videoView.setOnPreparedListener { mediaPlayer ->
|
||||
mediaPlayer.isLooping = true
|
||||
@@ -0,0 +1,793 @@
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.android.systemui") {
|
||||
Log.i(TAG, "SystemUI detected, hooking volume panel")
|
||||
try {
|
||||
val volumePanelViewClass = param.classLoader.loadClass("com.android.systemui.volume.VolumeDialogImpl")
|
||||
|
||||
try {
|
||||
val initDialogMethod = volumePanelViewClass.getDeclaredMethod("initDialog", Int::class.java)
|
||||
hook(initDialogMethod, VolumeDialogInitHooker::class.java)
|
||||
Log.i(TAG, "Hooked initDialog method successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook initDialog method: ${e.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
val showHMethod = volumePanelViewClass.getDeclaredMethod("showH", Int::class.java, Boolean::class.java, Int::class.java)
|
||||
hook(showHMethod, VolumeDialogShowHooker::class.java)
|
||||
Log.i(TAG, "Hooked showH method successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook showH method: ${e.message}")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Volume panel hook setup attempted on multiple methods")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook volume panel: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class VolumeDialogInitHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterInitDialog(callback: AfterHookCallback) {
|
||||
try {
|
||||
val volumeDialog = callback.thisObject
|
||||
Log.i(TAG, "Volume dialog initialized, adding AirPods controls")
|
||||
addAirPodsControlsToDialog(volumeDialog!!)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in initDialog hook: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class VolumeDialogShowHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterShowH(callback: AfterHookCallback) {
|
||||
try {
|
||||
val volumeDialog = callback.thisObject
|
||||
Log.i(TAG, "Volume dialog shown, ensuring AirPods controls are added")
|
||||
addAirPodsControlsToDialog(volumeDialog!!)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in showH hook: ${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,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
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
|
||||
|
||||
object MediaController {
|
||||
private var initialVolume: Int? = null
|
||||
@@ -34,11 +36,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 +52,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,17 +69,19 @@ 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.")
|
||||
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.
|
||||
}
|
||||
@@ -92,9 +97,9 @@ object MediaController {
|
||||
|
||||
@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(
|
||||
@@ -134,11 +139,18 @@ object MediaController {
|
||||
|
||||
@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)
|
||||
@@ -160,6 +172,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
|
||||
@@ -1,17 +1,17 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.aln.utils
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
@@ -45,7 +45,9 @@ enum class Enums(val value: ByteArray) {
|
||||
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));
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
|
||||
START_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00)),
|
||||
STOP_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E.toByte(), 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E.toByte(), 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00));
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
@@ -87,15 +89,15 @@ enum class NoiseControlMode {
|
||||
|
||||
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"
|
||||
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 {
|
||||
@@ -284,4 +286,57 @@ enum class LongPressPackets(val value: ByteArray) {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
//enum class LongPressMode {
|
||||
// OFF, TRANSPARENCY, ADAPTIVE, ANC
|
||||
//}
|
||||
//
|
||||
//data class LongPressPacket(val modes: Set<LongPressMode>) {
|
||||
// val value: ByteArray
|
||||
// get() {
|
||||
// val baseArray = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A)
|
||||
// val modeByte = calculateModeByte()
|
||||
// return baseArray + byteArrayOf(modeByte, 0x00, 0x00, 0x00)
|
||||
// }
|
||||
//
|
||||
// private fun calculateModeByte(): Byte {
|
||||
// var modeByte: Byte = 0x00
|
||||
// modes.forEach { mode ->
|
||||
// modeByte = when (mode) {
|
||||
// LongPressMode.OFF -> (modeByte + 0x01).toByte()
|
||||
// LongPressMode.TRANSPARENCY -> (modeByte + 0x02).toByte()
|
||||
// LongPressMode.ADAPTIVE -> (modeByte + 0x04).toByte()
|
||||
// LongPressMode.ANC -> (modeByte + 0x08).toByte()
|
||||
// }
|
||||
// }
|
||||
// return modeByte
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//fun determinePacket(changedIndex: Int, newEnabled: Boolean, oldModes: Set<LongPressMode>, newModes: Set<LongPressMode>): ByteArray? {
|
||||
// return if (newEnabled) {
|
||||
// LongPressPacket(oldModes + newModes.elementAt(changedIndex)).value
|
||||
// } else {
|
||||
// LongPressPacket(oldModes - newModes.elementAt(changedIndex)).value
|
||||
// }
|
||||
//}
|
||||
|
||||
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,272 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
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,613 @@
|
||||
/*
|
||||
* 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.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
|
||||
|
||||
@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
|
||||
@@ -17,18 +17,12 @@
|
||||
*/
|
||||
|
||||
|
||||
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
|
||||
|
||||
class BatteryWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
@@ -36,6 +30,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
|
||||
@@ -17,16 +17,17 @@
|
||||
*/
|
||||
|
||||
|
||||
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
|
||||
|
||||
class NoiseControlWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
@@ -77,6 +78,7 @@ class NoiseControlWidget : AppWidgetProvider() {
|
||||
super.onReceive(context, intent)
|
||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||
ServiceManager.getService()?.setANCMode(mode)
|
||||
}
|
||||
}
|
||||
|
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 |
BIN
android/app/src/main/res/drawable/pro_2_left.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
android/app/src/main/res/drawable/pro_2_right.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.0 KiB |
BIN
android/app/src/main/res/font/hack.otf
Normal file
BIN
android/app/src/main/res/font/sf_pro.otf
Normal file
@@ -1,12 +1,12 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/Widget.ALN.AppWidget.Container"
|
||||
style="@style/Widget.LibrePods.AppWidget.Container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="0dp"
|
||||
android:padding="0dp"
|
||||
android:id="@+id/battery_widget"
|
||||
android:theme="@style/Theme.ALN.AppWidgetContainer"
|
||||
android:theme="@style/Theme.LibrePods.AppWidgetContainer"
|
||||
android:background="@drawable/widget_background">
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/Widget.ALN.AppWidget.Container"
|
||||
style="@style/Widget.LibrePods.AppWidget.Container"
|
||||
android:id="@+id/noise_control_widget"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:theme="@style/Theme.ALN.AppWidgetContainer">
|
||||
android:theme="@style/Theme.LibrePods.AppWidgetContainer">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@android:id/background"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
<foreground android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||