Compare commits

..

68 Commits

Author SHA1 Message Date
Kavish Devar
d9469c2d62 android: not use relative paths for executing commands
i hope it's the same across all skins
2025-10-01 01:34:30 +05:30
Kavish Devar
b799cd1710 android: add option to change camera app id 2025-10-01 01:24:28 +05:30
Kavish Devar
c7dc545ed4 android: add camera control, finally
i got too lazy to find out how to listen to app openings earlier, wasn't too hard
2025-10-01 01:10:37 +05:30
Kavish Devar
342745ee2e android: add accessiblity service for camera control 2025-09-30 23:53:29 +05:30
Kavish Devar
8b49440d6b android: update styled slider thumb 2025-09-30 11:33:46 +05:30
Kavish Devar
993f022087 android: ui tweaks 2025-09-30 11:07:16 +05:30
Kavish Devar
650b128d5d docs: change section title in control cmd doc
Updated section title from 'Control Commands' to 'Identifiers and details'.
2025-09-29 17:18:37 +05:30
Kavish Devar
395feabb13 docs: new control cmds '25 (again) 2025-09-29 16:55:01 +05:30
Kavish Devar
6914dabe59 docs: app3 compatibility 2025-09-29 14:34:27 +05:30
Kavish Devar
78ae31c898 docs: update demo video position 2025-09-29 01:47:44 +05:30
Kavish Devar
b43e5f7526 docs: add new screenshots for android 2025-09-29 01:45:29 +05:30
Kavish Devar
9d60dc3682 docs: add demo video 2025-09-29 01:31:31 +05:30
Kavish Devar
c2ebbef14b docs: update README with new features 2025-09-29 01:00:41 +05:30
Kavish Devar
3a388da48e android: hide media assist, not implemented 2025-09-29 00:22:46 +05:30
Kavish Devar
bdb93efec6 android: prevent hearing aid turning off itself 2025-09-29 00:22:01 +05:30
Kavish Devar
504e70371b android: bring back original confirmation dialog
too lazy to fix/implement properly the glassy one
2025-09-29 00:17:02 +05:30
Kavish Devar
48b715af68 android: fix text color in troubshooting button and pressandhold settings 2025-09-28 18:13:52 +05:30
Kavish Devar
5ec300aad8 android: use lazycolumn in airpods settings for better performance and navigation transitions 2025-09-28 17:10:49 +05:30
Kavish Devar
e158ba1b27 android: don't crash if att not available 2025-09-28 17:01:42 +05:30
Kavish Devar
147e511659 android: show head gestures status in the navigation button 2025-09-28 16:03:55 +05:30
Kavish Devar
e9da7a2a50 android: fix crash in head gestures screen 2025-09-28 16:01:56 +05:30
Kavish Devar
1076218ccc android: add A16's new bluetooth identifier for log collection
just why...
2025-09-28 15:48:36 +05:30
Kavish Devar
55cb69f880 android: remove fade from transition 2025-09-28 15:41:43 +05:30
Kavish Devar
5bc1dd2e1d android: fix switch styling 2025-09-28 13:44:00 +05:30
Kavish Devar
1152f45a6c remove bleonly mode, use CAPod instead 2025-09-28 12:30:09 +05:30
Kavish Devar
3f582b8fcf remove bleonly mode, use CAPod instead 2025-09-28 12:27:42 +05:30
Kavish Devar
08738a1293 android: liquidglass, maybe?
the switch and icon button took quite a while. i forgot the order of modifiers matters!
2025-09-28 12:27:05 +05:30
Kavish Devar
8dc7a97c43 android: update usages for toggle 2025-09-26 14:03:47 +05:30
Kavish Devar
d9795c4d28 merge main into multi-device-and-accessibility 2025-09-26 03:38:29 +05:30
Kavish Devar
56307c98e3 android: revert accidental capitalization on toggle label 2025-09-26 03:27:32 +05:30
Kavish Devar
ab55096051 android: move padding to StyledScaffold's content
because haze needs it
2025-09-26 03:26:25 +05:30
Kavish Devar
86a6a28dc1 android: a very big commit
refactoring ui, mostly
2025-09-26 03:22:01 +05:30
Kavish Devar
7e5ee6726f android: small ui tweaks 2025-09-23 23:58:06 +05:30
Kavish Devar
5f08edd49c android: remove unused strings 2025-09-23 11:14:31 +05:30
Kavish Devar
29a35ceebe android: remove customdeviceactivity from manifest 2025-09-23 11:03:55 +05:30
Kavish Devar
173e06c5e7 android: fix hearing aid parsing 2025-09-23 02:53:10 +05:30
Kavish Devar
26de42243f android: little more liquid glass 2025-09-23 01:20:41 +05:30
Kavish Devar
8760757b76 android: improve liquid glass sliders 2025-09-23 00:27:39 +05:30
Kavish Devar
4bc76de750 android: liquidglass sliders 2025-09-23 00:03:03 +05:30
Kavish Devar
4751f70579 android: add hearing aid adjustments 2025-09-22 14:54:54 +05:30
Kavish Devar
ce229bec6e android: add media assist options in hearing aid
ui only
2025-09-22 10:44:48 +05:30
Kavish Devar
fe69082e11 android: add ui for hearing stuff
mostly copied from the transparency settings, which are now updated to match ios <26 ui
2025-09-22 00:59:39 +05:30
Kavish Devar
3ace0e1831 android: move attmanager to service to avoid trying to connect multiple times 2025-09-21 22:15:44 +05:30
Kavish Devar
ecfdc05dbf android: improve dropdowns
ai generated
2025-09-21 01:34:42 +05:30
Kavish Devar
5aeb47b835 android: add microphone setting
also, un-hardcoded strings, and updated text sizes
2025-09-20 22:55:35 +05:30
Kavish Devar
3cca786cf9 docs: a few more control cmds 2025-09-20 01:45:06 +05:30
Kavish Devar
6fd3cc1eb0 android: a small ui fix 2025-09-20 01:44:36 +05:30
Kavish Devar
bb69a74a8e android: add a few options
ik not the right branch/pr but, eh, i am not merging this hook until i test further, and if i don't merge, conflicts, a lot of 'em
2025-09-20 01:43:24 +05:30
Kavish Devar
71a1f834cb android: add delay before starting head tracking again 2025-09-19 23:38:38 +05:30
Kavish Devar
63baa153da android: fix text color in selectors 2025-09-19 18:16:02 +05:30
Kavish Devar
5eff5b9d77 android: update eq sliders style 2025-09-19 18:12:56 +05:30
Kavish Devar
b5103a28e7 android: remove unused composable 2025-09-19 18:10:00 +05:30
Kavish Devar
3699ee6bee android: fix track color in tone volume 2025-09-19 18:08:31 +05:30
Kavish Devar
032b94e3ae android: use device name sent by the connected device in island 2025-09-19 16:27:32 +05:30
Kavish Devar
5c9beeb26d android: add header to ATTManager 2025-09-19 14:29:55 +05:30
Kavish Devar
65d074efe0 android: bring back some accessiblity settings and add listeners for all config 2025-09-19 13:11:04 +05:30
Kavish Devar
93328d281e android: fix balance NaN error when amplification L/R is both zero 2025-09-18 13:56:06 +05:30
Kavish Devar
792629acb9 docs: add 'has ownership' control cmd 2025-09-15 20:01:46 +05:30
Kavish Devar
5bef8c384e android: add toggle for DID hook 2025-09-15 19:59:43 +05:30
Kavish Devar
9e6d97198b android: add EQ settings for phone and media 2025-09-15 11:49:00 +05:30
Kavish Devar
c53356f77e android: implement the accessiblity settings page 2025-09-11 12:21:23 +05:30
Kavish Devar
fa00620b5b android: clean up a lot of stuff 2025-09-10 12:38:27 +05:30
Kavish Devar
aecbb066b5 android: clean up main service and remove minimum API on head gestures 2025-09-10 11:32:48 +05:30
Kavish Devar
0e9aadd672 android: clean up a bit of AI gen'd code 2025-09-10 11:24:51 +05:30
Kavish Devar
df9f443173 android: add basic multidevice capabilities
use at your own risk, may or may not work
2025-09-10 10:03:52 +05:30
Kavish Devar
d1bf5407c9 android: don't start service every time MainActivity is launched 2025-09-09 16:33:07 +05:30
Kavish Devar
4ee9b2732f docs: update transparency mode format 2025-09-08 00:24:15 +05:30
Kavish Devar
86551be86b android: add accessibility stuff
adds option for customizing transparency mode, amplification, tone, etc.
2025-09-08 00:23:45 +05:30
169 changed files with 3356 additions and 13128 deletions

View File

@@ -16,5 +16,5 @@ indent_size = 4
trim_trailing_whitespace = false trim_trailing_whitespace = false
max_line_length = off max_line_length = off
[*.{py,java,r,R,kt,xml,kts,h,hpp,cpp,qml}] [*.{py,java,r,R,kt,xml,kts}]
indent_size = 4 indent_size = 4

View File

@@ -4,8 +4,6 @@ on:
push: push:
branches: branches:
- '*' - '*'
paths:
- 'android/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: release:

View File

@@ -1,87 +0,0 @@
name: Linux Build & Release
on:
push:
branches:
- linux/rust
tags:
- 'linux-v*'
paths:
- 'linux-rust/**'
- '.github/workflows/linux-build.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2
- name: Install AppImage tools
run: |
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
chmod +x /usr/local/bin/{appimagetool,linuxdeploy}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
linux-rust/target
key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }}
- name: Build AppImage and Binary
working-directory: linux-rust
run: |
cargo build --release --verbose
just
mkdir -p dist
cp target/release/librepods dist/librepods
mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage
- name: Upload AppImage artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods-x86_64.AppImage
path: linux-rust/dist/librepods-x86_64.AppImage
- name: Upload binary artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods
path: linux-rust/dist/librepods
- name: Create tarball for Flatpak
if: startsWith(github.ref, 'refs/tags/linux-v')
working-directory: linux-rust
run: |
VERSION="${GITHUB_REF_NAME#linux-v}"
just tarball "${VERSION}"
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV
- name: Create GitHub Release (AppImage + binary + source)
if: startsWith(github.ref, 'refs/tags/linux-v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz
linux-rust/dist/librepods-x86_64.AppImage
linux-rust/dist/librepods
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,10 +1,9 @@
name: Build LibrePods Linux name: Build LibrePods Linux
on: on:
workflow_dispatch: push:
# push: branches:
# branches: - '*'
# - '*'
jobs: jobs:
build-linux: build-linux:

View File

@@ -122,7 +122,7 @@ If primary is removed, mic will be changed and the secondary will be the new pri
## Conversational Awareness ## Conversational Awareness
AirPods send conversational awareness packets when the person wearing them starts speaking. The packet format is as follows: AirPods send conversational awareness packets when the person wearing them start speaking. The packet format is as follows:
```plaintext ```plaintext
04 00 04 00 4B 00 02 00 01 [level] 04 00 04 00 4B 00 02 00 01 [level]
@@ -307,7 +307,7 @@ All values are formatted as IEEE 754 floats in little endian order.
## Configure Stem Long Press ## Configure Stem Long Press
I have noted all the packets sent to configure what the press and hold of the steam should do. The packets sent are specific to the current state. And are probably overwritten everytime the AirPods are connected to a new (apple) device that is not synced with icloud (i think)... So, for non-Apple devices too, the configuration needs to be stored and overwritten everytime the AirPods are connected to the device. That is the only way to keep the configuration. I have noted all the packets sent to configure what the press and hold of the steam should do. The packets sent are specific to the current state. And are probably overwritten everytime the AirPods are connected to a new (apple) device that is not synced with icloud (i think)... So, for non-Apple device too, the configuration needs to be stored and overwritten everytime the AirPods are connected to the device. That is the only way to keep the configuration.
This is also the only way to control the configuration as the previous state needs to be known, and then the new state can be set. This is also the only way to control the configuration as the previous state needs to be known, and then the new state can be set.
@@ -403,3 +403,20 @@ Once tracking is active, the AirPods stream sensor packets with the following co
| orientation 3 | 47 | 2 | | orientation 3 | 47 | 2 |
| Horizontal Acceleration | 51 | 2 | | Horizontal Acceleration | 51 | 2 |
| Vertical Acceleration | 53 | 2 | | Vertical Acceleration | 53 | 2 |
# LICENSE
LibrePods - AirPods liberated from Apples 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/>.

128
CODE_OF_CONDUCT.md Normal file
View File

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

70
CONTRIBUTING.md Normal file
View File

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

145
LICENSE
View File

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

128
README.md
View File

@@ -1,5 +1,14 @@
![LibrePods Banner](/imgs/banner.png) ![LibrePods Banner](/imgs/banner.png)
[![XDA Thread](https://img.shields.io/badge/XDA_Forums-Thread-orange)](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/releases/latest)
[![GitHub all releases](https://img.shields.io/github/downloads/kavishdevar/librepods/total)](https://github.com/kavishdevar/librepods/releases)
[![GitHub stars](https://img.shields.io/github/stars/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/stargazers)
[![GitHub issues](https://img.shields.io/github/issues/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/issues)
[![GitHub license](https://img.shields.io/github/license/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
[![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors)
## What is LibrePods? ## What is LibrePods?
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
@@ -7,13 +16,12 @@ LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get a
## Device Compatibility ## Device Compatibility
| Status | Device | Features | | Status | Device | Features |
| ------ | --------------------- | ---------------------------------------------------------- | |--------|--------|----------|
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | | ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | | ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | | ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS). Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with.
## Key Features ## Key Features
@@ -28,31 +36,39 @@ Most features should work with any AirPods. Currently, I've only got AirPods Pro
- **Other customizations**: - **Other customizations**:
- Rename your AirPods - Rename your AirPods
- Customize long-press actions - Customize long-press actions
- All accessibility settings - Few accessibility features
- And more! - And more!
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple. See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
## Platform Support ## Platform Support
### Linux ### Linux
for the old version see the [Linux README](/linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241)) The Linux version runs as a system tray app. Connect your AirPods and enjoy:
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28) - Battery monitoring
- Automatic Ear detection
- Conversational Awareness
- Switching Noise Control modes
- Device renaming
> [!NOTE]
> Work in progress, but core functionality is stable and usable.
For installation and detailed info, see the [Linux README](/linux/README.md).
### Android ### Android
#### Screenshots #### Screenshots
| | | | | | | |
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | |-------------------|-------------------|-------------------|
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | | ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) | | ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) | | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) | ![transparency](/android/imgs/transparency.png) | | ![Customizations 2](/android/imgs/customizations-2.png) | ![accessibility](/android/imgs/accessibility.png) |![transparency](/android/imgs/transparency.png) |
| ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | |![hearing-aid](/android/imgs/hearing-aid.png) |![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | |
here's a very unprofessional demo video here's a very unprofessional demo video
@@ -61,20 +77,14 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
#### Root Requirement #### Root Requirement
If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
> [!CAUTION] > [!CAUTION]
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+. > **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/your OEM figures out a fix. > There are **no exceptions** to the root requirement until Google merges the fix.
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features. ## Bluetooth DID (Device Identification) Hook
## Changing VendorID in the DID profile to that of Apple Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
### Multi-device Connectivity ### Multi-device Connectivity
@@ -84,44 +94,53 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
#### Installation Methods
##### Method 1: Xposed Module (Recommended)
This method is less intrusive and should be tried first:
1. Install LSPosed, or another Xposed provider on your rooted device
2. Download the LibrePods app from the releases section, and install it.
3. Enable the Xposed module for the bluetooth app in your Xposed manager.
4. Disable unmount modules for the Bluetooth app if enabled.
5. Follow the instructions in the app to set up the module.
6. Open the app and connect your AirPods
##### Method 2: Root Module (Backup Option)
If the Xposed method doesn't work for you:
1. Download the `btl2capfix.zip` module from the releases section
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
3. Disable Unmount modules for the Bluetooth aop if enabled.
4. Reboot your device
5. Connect your AirPods
##### Method 3: Patching it yourself
If you prefer to patch the Bluetooth stack yourself, follow these steps:
1. Look for the library in use by running `lsof | grep libbluetooth`
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
3. Find the `l2c_fcr_chk_chan_modes` function in the library
4. Patch the function to always return `1` (true)
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
6. Reboot your device
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
#### A few notes #### 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, loud sounds are not reduced. - 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. - 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. - When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
## Supporters
A huge thank you to everyone supporting the project!
- @davdroman
- @tedsalmon
- @wiless
- @SmartMsg
- @lunaroyster
- @ressiwage
## Special thanks
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
- @devnoname120 for helping with the first root patch
- @timgromeyer for making the first version of the linux app
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](low-skies.hackclub.com)!
## Star History ## Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left"> [![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date)
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
</picture>
</a>
# License # License
@@ -129,16 +148,15 @@ LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU Affero General Public License as published
the Free Software Foundation, either version 3 of the License, or by the Free Software Foundation, either version 3 of the License.
any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc. All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.

View File

@@ -2,20 +2,19 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
android { android {
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 36 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 minSdk = 28
targetSdk = 36 targetSdk = 35
versionCode = 9 versionCode = 7
versionName = "0.2.0" versionName = "0.1.0-rc.4"
} }
buildTypes { buildTypes {
@@ -38,20 +37,12 @@ android {
compose = true compose = true
viewBinding = true viewBinding = true
} }
androidResources {
generateLocaleConfig = true
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1" version = "3.22.1"
} }
} }
sourceSets {
getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple")
}
}
} }
dependencies { dependencies {
@@ -74,19 +65,9 @@ dependencies {
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) // implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
compileOnly(files("libs/libxposed-api-100.aar")) compileOnly(files("libs/libxposed-api-100.aar"))
debugImplementation(files("libs/backdrop-debug.aar")) debugImplementation(files("libs/backdrop-debug.aar"))
releaseImplementation(files("libs/backdrop-release.aar")) releaseImplementation(files("libs/backdrop-release.aar"))
} }
aboutLibraries {
export{
prettyPrint = true
excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json")
}
}

View File

@@ -35,6 +35,8 @@
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -60,7 +62,6 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/noise_control_widget_info" /> android:resource="@xml/noise_control_widget_info" />
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.BatteryWidget" android:name=".widgets.BatteryWidget"
android:exported="false"> android:exported="false">

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -27,6 +27,7 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -37,16 +38,11 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -91,8 +87,6 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -110,10 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
@@ -124,17 +115,14 @@ import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -156,6 +144,10 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
LibrePodsTheme { LibrePodsTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit {
putLong(
"textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong())}
Main() Main()
} }
} }
@@ -308,16 +300,22 @@ fun Main() {
val navController = rememberNavController() 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 ( Box (
modifier = Modifier modifier = Modifier
.fillMaxSize() .padding(0.dp)
){
val backButtonBackdrop = rememberLayerBackdrop()
Box (
modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
.layerBackdrop(backButtonBackdrop)
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -377,7 +375,7 @@ fun Main() {
TroubleshootingScreen(navController) TroubleshootingScreen(navController)
} }
composable("head_tracking") { composable("head_tracking") {
HeadTrackingScreen() HeadTrackingScreen(navController)
} }
composable("onboarding") { composable("onboarding") {
Onboarding(navController, context) Onboarding(navController, context)
@@ -400,47 +398,6 @@ fun Main() {
composable("camera_control") { composable("camera_control") {
CameraControlScreen(navController) CameraControlScreen(navController)
} }
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
UpdateHearingTestScreen(navController)
}
composable("version_info") {
VersionScreen(navController)
}
composable("hearing_protection") {
HearingProtectionScreen(navController)
}
}
}
val showBackButton = remember{ mutableStateOf(false) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
}
}
AnimatedVisibility(
visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = 8.dp,
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
)
) {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop
)
} }
} }
@@ -565,7 +522,7 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.permissions_required), text = "The following permissions are required to use the app. Please grant them to continue.",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -742,11 +699,7 @@ fun PermissionCard(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background( .background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)),
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f
)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,205 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.about),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.model.displayName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.actualModelNumber,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
airpodsInstance.serialNumber?: "",
"􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${airpodsInstance.rightSerialNumber}"
)
val serialNumber = remember { mutableStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.serial_number),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Text(
text = serialNumbers[serialNumber.value],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
}
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
currentState = airpodsInstance.version3,
independent = false,
height = rowHeight.value + 32.dp
)
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -42,27 +42,15 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings(navController: NavController) { fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -88,7 +76,6 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
@@ -100,11 +87,9 @@ fun AudioSettings(navController: NavController) {
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal= 12.dp)
) )
}
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
@@ -115,11 +100,9 @@ fun AudioSettings(navController: NavController) {
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal= 12.dp)
) )
}
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
@@ -130,11 +113,9 @@ fun AudioSettings(navController: NavController) {
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal= 12.dp)
) )
}
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
NavigationButton( NavigationButton(
to = "adaptive_strength", to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
@@ -142,7 +123,6 @@ fun AudioSettings(navController: NavController) {
independent = false independent = false
) )
} }
}
} }
@Preview @Preview

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -135,13 +135,6 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val singleDisplayed = remember { mutableStateOf(false) } val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row { Row {
Column ( Column (
modifier = Modifier modifier = Modifier
@@ -149,7 +142,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image ( Image (
bitmap = ImageBitmap.imageResource(budsRes), bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
contentDescription = stringResource(R.string.buds), contentDescription = stringResource(R.string.buds),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -205,7 +198,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image( Image(
bitmap = ImageBitmap.imageResource(caseRes), bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = stringResource(R.string.case_alt), contentDescription = stringResource(R.string.case_alt),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -180,13 +180,7 @@ fun ConfirmationDialog(
.background(if (leftPressed) pressedColor else Color.Transparent), .background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(dismissText, color = accentColor)
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
} }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -201,13 +195,7 @@ fun ConfirmationDialog(
.background(if (rightPressed) pressedColor else Color.Transparent), .background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(confirmText, color = accentColor)
text = confirmText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
} }
} }
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -61,7 +61,7 @@ fun ConnectionSettings() {
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal= 12.dp)
) )
StyledToggle( StyledToggle(

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("unused") @file:Suppress("unused")

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,109 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -47,7 +47,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -60,8 +59,7 @@ fun NavigationButton(
independent: Boolean = true, independent: Boolean = true,
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
currentState: String? = null, currentState: String? = null
height: Dp = 58.dp,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
@@ -86,7 +84,7 @@ fun NavigationButton(
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
.height(height) .height(58.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -127,8 +127,8 @@ half4 main(float2 coord) {
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
shadow = { shadow = {
Shadow( Shadow(
radius = 12f.dp, radius = 48f.dp,
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f) color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.4f)
) )
}, },
layerBlock = { layerBlock = {
@@ -136,7 +136,8 @@ half4 main(float2 coord) {
val height = size.height val height = size.height
val progress = progressAnimation.value val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress) val maxScale = 0.1f
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
@@ -219,7 +220,7 @@ half4 main(float2 coord) {
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
// blur(24f, TileMode.Decal) blur(24f, TileMode.Decal)
}, },
) )
.pointerInput(animationScope) { .pointerInput(animationScope) {

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -36,8 +35,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -49,10 +46,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
@@ -65,7 +58,8 @@ import me.kavishdevar.librepods.R
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) { ) {
@@ -74,10 +68,7 @@ fun StyledScaffold(
Scaffold( Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) }
modifier = Modifier
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
.clip(RoundedCornerShape(52.dp))
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding() val bottomPadding = paddingValues.calculateBottomPadding()
@@ -89,20 +80,22 @@ fun StyledScaffold(
.fillMaxSize() .fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding) .padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) { ) {
val backdrop = rememberLayerBackdrop()
Box( Box(
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.height(64.dp + topPadding) .height(64.dp + topPadding)
.fillMaxWidth() .fillMaxWidth()
.layerBackdrop(backdrop)
.hazeEffect(state = hazeState) { .hazeEffect(state = hazeState) {
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White)) tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
} }
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding + 12.dp)) Spacer(modifier = Modifier.height(topPadding))
Box(
modifier = Modifier.fillMaxWidth()
) {
navigationButton()
Text( Text(
text = title, text = title,
style = TextStyle( style = TextStyle(
@@ -111,19 +104,15 @@ fun StyledScaffold(
color = if (isDarkTheme) Color.White else Color.Black, color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
}
}
Row( Row(
modifier = Modifier modifier = Modifier.align(Alignment.CenterEnd)
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
) { ) {
actionButtons.forEach { actionButton -> actionButtons.forEach { it() }
actionButton(backdrop) }
}
} }
} }
@@ -137,14 +126,16 @@ fun StyledScaffold(
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
StyledScaffold( StyledScaffold(
title = title, title = title,
navigationButton = navigationButton,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState
) { _, _ -> ) { _, _ ->
content() content()
} }
@@ -154,14 +145,16 @@ fun StyledScaffold(
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp) -> Unit content: @Composable (spacerValue: Dp) -> Unit
) { ) {
StyledScaffold( StyledScaffold(
title = title, title = title,
navigationButton = navigationButton,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState
) { spacerValue, _ -> ) { spacerValue, _ ->
content(spacerValue) content(spacerValue)
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -106,11 +106,7 @@ fun StyledSwitch(
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
if (!isFirstComposition.value) {
coroutineScope { coroutineScope {
launch { launch {
val targetColor = if (checked) onColor else offColor val targetColor = if (checked) onColor else offColor
@@ -120,15 +116,8 @@ fun StyledSwitch(
val targetFrac = if (checked) 1f else 0f val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec) animatedFraction.animateTo(targetFrac, progressAnimationSpec)
} }
if (progressAnimation.value > 0f) return@coroutineScope
launch {
progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing))
progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing))
} }
} }
}
isFirstComposition.value = false
}
Box( Box(
modifier = Modifier modifier = Modifier
@@ -158,7 +147,6 @@ fun StyledSwitch(
animationScope.launch { animationScope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += kotlin.math.abs(delta)
val newChecked = newFraction >= 0.5f val newChecked = newFraction >= 0.5f
if (newChecked != checked) { if (newChecked != checked) {
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -168,22 +156,12 @@ fun StyledSwitch(
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
totalDrag.floatValue = 0f
animationScope.launch { animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
animationScope.launch { animationScope.launch {
if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked
onCheckedChange(newChecked)
val snappedFraction = if (newChecked) 1f else 0f
coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
} else {
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
onCheckedChange(snappedFraction >= 0.5f) onCheckedChange(snappedFraction >= 0.5f)
coroutineScope { coroutineScope {
@@ -192,7 +170,6 @@ fun StyledSwitch(
} }
} }
} }
}
) else Modifier) ) else Modifier)
.drawBackdrop( .drawBackdrop(
rememberCombinedBackdrop(backdrop, switchBackdrop), rememberCombinedBackdrop(backdrop, switchBackdrop),

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -59,6 +59,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -472,18 +473,31 @@ fun StyledToggle(
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = try { var checked by remember { mutableStateOf(false) }
attManager.read(attHandle).getOrNull(0)?.toInt()
} catch (e: Exception) {
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
null
} ?: 0
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
LaunchedEffect(Unit) {
attManager.enableNotifications(attHandle) attManager.enableNotifications(attHandle)
var parsed = false
for (attempt in 1..3) {
try {
val data = attManager.read(attHandle)
checked = data[0].toInt() != 0
Log.d("StyledToggle", "Read attempt $attempt for $label: enabled=$checked")
parsed = true
break
} catch (e: Exception) {
Log.w("StyledToggle", "Read attempt $attempt for $label failed: ${e.message}")
}
delay(200)
}
if (!parsed) {
Log.d("StyledToggle", "Failed to read state for $label after 3 attempts")
}
}
if (sharedPreferenceKey != null && sharedPreferences != null) { if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.constants

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.constants

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,25 +1,27 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -33,10 +35,17 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -50,6 +59,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -77,13 +88,13 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -106,8 +117,6 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
val hearingAidEnabled = remember { mutableStateOf( val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() && aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte() aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
@@ -141,7 +150,15 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.accessibility) title = stringResource(R.string.accessibility),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -160,9 +177,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val mediaEQEnabled = remember { mutableStateOf(false) } val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to "Default",
1.toByte() to stringResource(R.string.slower), 1.toByte() to "Slower",
2.toByte() to stringResource(R.string.slowest) 2.toByte() to "Slowest"
) )
val selectedPressSpeedValue = val selectedPressSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -196,9 +213,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
val pressAndHoldDurationOptions = mapOf( val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to "Default",
1.toByte() to stringResource(R.string.slower), 1.toByte() to "Slower",
2.toByte() to stringResource(R.string.slowest) 2.toByte() to "Slowest"
) )
val selectedPressAndHoldDurationValue = val selectedPressAndHoldDurationValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -234,9 +251,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
val volumeSwipeSpeedOptions = mapOf( val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option), 1.toByte() to "Default",
2.toByte() to stringResource(R.string.longer), 2.toByte() to "Longer",
3.toByte() to stringResource(R.string.longest) 3.toByte() to "Longest"
) )
val selectedVolumeSwipeSpeedValue = val selectedVolumeSwipeSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -322,7 +339,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description), description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(), options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option), selectedOption = selectedPressSpeed?: "Default",
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressSpeed = newValue selectedPressSpeed = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
@@ -340,7 +357,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
label = stringResource(R.string.press_and_hold_duration), label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description), description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(), options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option), selectedOption = selectedPressAndHoldDuration?: "Default",
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
@@ -362,13 +379,11 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true, independent = true,
) )
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION attHandle = ATTHandles.LOUD_SOUND_REDUCTION
) )
}
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton( NavigationButton(
@@ -392,7 +407,6 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true independent = true
) )
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
@@ -403,7 +417,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description), description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(), options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option), selectedOption = selectedVolumeSwipeSpeed?: "Default",
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
@@ -416,230 +430,228 @@ fun AccessibilitySettingsScreen(navController: NavController) {
hazeState = hazeState, hazeState = hazeState,
independent = true independent = true
) )
}
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
// Text( Text(
// text = stringResource(R.string.apply_eq_to), text = stringResource(R.string.apply_eq_to),
// style = TextStyle( style = TextStyle(
// fontSize = 14.sp, fontSize = 14.sp,
// fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
// ), ),
// modifier = Modifier.padding(8.dp, bottom = 0.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
// ) )
// Column( Column(
// modifier = Modifier modifier = Modifier
// .fillMaxWidth() .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
// .padding(vertical = 0.dp) .padding(vertical = 0.dp)
// ) { ) {
// val darkModeLocal = isSystemInDarkTheme() val darkModeLocal = isSystemInDarkTheme()
//
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
// var phoneBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val phoneAnimatedBackgroundColor by animateColorAsState(
// targetValue = phoneBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(phoneAnimatedBackgroundColor, phoneShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// phoneEQEnabled.value = !phoneEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.phone),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = phoneEQEnabled.value,
// onCheckedChange = { phoneEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888)
// )
//
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
// var mediaBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val mediaAnimatedBackgroundColor by animateColorAsState(
// targetValue = mediaBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(mediaAnimatedBackgroundColor, mediaShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// mediaEQEnabled.value = !mediaEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.media),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = mediaEQEnabled.value,
// onCheckedChange = { mediaEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
// }
// EQ Settings. Don't seem to have an effect? val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
// Column( var phoneBackgroundColor by remember {
// modifier = Modifier mutableStateOf(
// .fillMaxWidth() if (darkModeLocal) Color(
// .background(backgroundColor, RoundedCornerShape(28.dp)) 0xFF1C1C1E
// .padding(12.dp), ) else Color(0xFFFFFFFF)
// horizontalAlignment = Alignment.CenterHorizontally )
// ) { }
// for (i in 0 until 8) { val phoneAnimatedBackgroundColor by animateColorAsState(
// val eqPhoneValue = targetValue = phoneBackgroundColor,
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } animationSpec = tween(durationMillis = 500)
// Row( )
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
// Slider( Row(
// value = eqPhoneValue.floatValue, modifier = Modifier
// onValueChange = { newVal -> .height(48.dp)
// eqPhoneValue.floatValue = newVal .fillMaxWidth()
// val newEQ = phoneMediaEQ.value.copyOf() .background(phoneAnimatedBackgroundColor, phoneShape)
// newEQ[i] = eqPhoneValue.floatValue .pointerInput(Unit) {
// phoneMediaEQ.value = newEQ detectTapGestures(
// }, onPress = {
// valueRange = 0f..100f, phoneBackgroundColor =
// modifier = Modifier if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// .fillMaxWidth(0.9f) tryAwaitRelease()
// .height(36.dp), phoneBackgroundColor =
// colors = SliderDefaults.colors( if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// thumbColor = thumbColor, phoneEQEnabled.value = !phoneEQEnabled.value
// activeTrackColor = activeTrackColor, }
// inactiveTrackColor = trackColor )
// ), }
// thumb = { .padding(horizontal = 16.dp),
// Box( verticalAlignment = Alignment.CenterVertically
// modifier = Modifier ) {
// .size(24.dp) Text(
// .shadow(4.dp, CircleShape) stringResource(R.string.phone),
// .background(thumbColor, CircleShape) fontSize = 16.sp,
// ) color = textColor,
// }, fontFamily = FontFamily(Font(R.font.sf_pro)),
// track = { modifier = Modifier.weight(1f)
// Box( )
// modifier = Modifier Checkbox(
// .fillMaxWidth() checked = phoneEQEnabled.value,
// .height(12.dp), onCheckedChange = { phoneEQEnabled.value = it },
// contentAlignment = Alignment.CenterStart colors = CheckboxDefaults.colors().copy(
// ) checkedCheckmarkColor = Color(0xFF007AFF),
// { uncheckedCheckmarkColor = Color.Transparent,
// Box( checkedBoxColor = Color.Transparent,
// modifier = Modifier uncheckedBoxColor = Color.Transparent,
// .fillMaxWidth() checkedBorderColor = Color.Transparent,
// .height(4.dp) uncheckedBorderColor = Color.Transparent
// .background(trackColor, RoundedCornerShape(4.dp)) ),
// ) modifier = Modifier
// Box( .height(24.dp)
// modifier = Modifier .scale(1.5f)
// .fillMaxWidth(eqPhoneValue.floatValue / 100f) )
// .height(4.dp) }
// .background(activeTrackColor, RoundedCornerShape(4.dp))
// )
// }
// }
// )
// Text( HorizontalDivider(
// text = stringResource(R.string.band_label, i + 1), thickness = 1.dp,
// fontSize = 12.sp, color = Color(0x40888888)
// color = textColor, )
// modifier = Modifier.padding(top = 4.dp)
// ) val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
// } var mediaBackgroundColor by remember {
// } mutableStateOf(
// } if (darkModeLocal) Color(
0xFF1C1C1E
) else Color(0xFFFFFFFF)
)
}
val mediaAnimatedBackgroundColor by animateColorAsState(
targetValue = mediaBackgroundColor,
animationSpec = tween(durationMillis = 500)
)
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(mediaAnimatedBackgroundColor, mediaShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
mediaBackgroundColor =
if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
mediaBackgroundColor =
if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
mediaEQEnabled.value = !mediaEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.media),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = mediaEQEnabled.value,
onCheckedChange = { mediaEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
for (i in 0 until 8) {
val eqPhoneValue =
remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqPhoneValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqPhoneValue.floatValue,
onValueChange = { newVal ->
eqPhoneValue.floatValue = newVal
val newEQ = phoneMediaEQ.value.copyOf()
newEQ[i] = eqPhoneValue.floatValue
phoneMediaEQ.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqPhoneValue.floatValue / 100f)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
} }
} }
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -99,7 +99,15 @@ fun AdaptiveStrengthScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio) title = stringResource(R.string.customize_adaptive_audio),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -65,25 +65,20 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.NoiseControlSettings
@@ -96,8 +91,6 @@ import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -203,7 +196,30 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
} }
val darkMode = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = deviceName.text,
actionButtons = listOf {
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
darkMode = darkMode,
backdrop = backdrop
)
},
snackbarHostState = snackbarHostState
) { spacerHeight, hazeState ->
if (isLocallyConnected || isRemotelyConnected) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
.layerBackdrop(backdrop)
) {
item { Spacer(modifier = Modifier.height(spacerHeight)) }
item {
LaunchedEffect(service) { LaunchedEffect(service) {
service.let { service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
@@ -215,48 +231,11 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
val darkMode = isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
val showDialog = remember { mutableStateOf(false) }
StyledScaffold(
title = deviceName.text,
actionButtons = listOf(
{scaffoldBackdrop ->
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
darkMode = darkMode,
backdrop = scaffoldBackdrop
)
}
),
snackbarHostState = snackbarHostState
) { spacerHeight, hazeState ->
hazeStateS.value = hazeState
if (isLocallyConnected || isRemotelyConnected) {
val instance = service.airpodsInstance
if (instance == null) {
Text("Error: AirPods instance is null")
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item(key = "battery") {
BatteryView(service = service) BatteryView(service = service)
} }
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) } item { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "name") { item {
NavigationButton( NavigationButton(
to = "rename", to = "rename",
name = stringResource(R.string.name), name = stringResource(R.string.name),
@@ -265,76 +244,61 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true independent = true
) )
} }
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
if (actAsAppleDeviceHookEnabled) {
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "hearing_health") {
HearingHealthSettings(navController = navController)
}
}
if (capabilities.contains(Capability.LISTENING_MODE)) { item { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) } item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) }
item(key = "noise_control") { NoiseControlSettings(service = service) }
}
if (capabilities.contains(Capability.STEM_CONFIG)) { item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) } item { NoiseControlSettings(service = service) }
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
}
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { CallControlSettings(hazeState = hazeState) } item { PressAndHoldSettings(navController = navController) }
if (capabilities.contains(Capability.STEM_CONFIG)) { item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } item { CallControlSettings(hazeState = hazeState) }
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") { AudioSettings(navController = navController) } item { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") { ConnectionSettings() } item { AudioSettings(navController = navController) }
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") { MicrophoneSettings(hazeState) } item { ConnectionSettings() }
if (capabilities.contains(Capability.SLEEP_DETECTION)) { item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } item { MicrophoneSettings(hazeState) }
item(key = "sleep_detection") {
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
StyledToggle( StyledToggle(
label = stringResource(R.string.sleep_detection), label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
) )
} }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off))
} }
if (capabilities.contains(Capability.HEAD_GESTURES)) { item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } item {
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") {
StyledToggle( StyledToggle(
label = stringResource(R.string.off_listening_mode), label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description) description = stringResource(R.string.off_listening_mode_description)
) )
} }
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } // an about card- everything but the version number is unknown - will add later if i find out
item(key = "about") { AboutCard(navController = navController) }
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "debug") { NavigationButton("debug", "Debug", navController) } item { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) } item { Spacer(Modifier.height(24.dp)) }
} }
} }
else { else {
@@ -350,7 +314,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Highlight.Ambient.copy(alpha = 0f) Highlight.Ambient.copy(alpha = 0f)
} }
) )
.hazeSource(hazeState)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@@ -381,31 +344,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
StyledButton( StyledButton(
onClick = { navController.navigate("troubleshooting") }, onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop, backdrop = backdrop
modifier = Modifier
.fillMaxWidth(0.9f)
) { ) {
Text( Text(
text = stringResource(R.string.troubleshooting), text = "Troubleshoot Connection",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
StyledButton(
onClick = {
service.reconnectFromSavedMac()
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -417,25 +359,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
} }
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.support_librepods),
message = stringResource(R.string.support_dialog_description),
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
dismissText = stringResource(R.string.never_show_again),
onConfirm = {
val browserIntent = Intent(
Intent.ACTION_VIEW,
"https://github.com/sponsors/kavishdevar".toUri()
)
context.startActivity(browserIntent)
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
onDismiss = {
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
hazeState = hazeStateS.value,
)
} }
@Preview @Preview

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -80,6 +80,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
@@ -192,7 +193,15 @@ fun AppSettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.app_settings) title = stringResource(R.string.app_settings),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -639,15 +648,6 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "open_source_licenses",
name = stringResource(R.string.open_source_licenses),
navController = navController,
independent = true
)
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
if (showResetDialog.value) { if (showResetDialog.value) {

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -130,7 +130,15 @@ fun CameraControlScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.camera_control) title = stringResource(R.string.camera_control),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@@ -327,8 +327,16 @@ fun DebugScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = "Debug", title = "Debug",
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf( actionButtons = listOf(
{scaffoldBackdrop -> {
StyledIconButton( StyledIconButton(
onClick = { onClick = {
airPodsService?.clearLogs() airPodsService?.clearLogs()
@@ -336,7 +344,7 @@ fun DebugScreen(navController: NavController) {
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = backdrop
) )
} }
), ),

View File

@@ -1,23 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -86,6 +83,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -110,7 +108,7 @@ import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen() { fun HeadTrackingScreen(navController: NavController) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking() ServiceManager.getService()?.startHeadTracking()
onDispose { onDispose {
@@ -123,10 +121,18 @@ fun HeadTrackingScreen() {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold (
title = stringResource(R.string.head_tracking), title = stringResource(R.string.head_tracking),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf( actionButtons = listOf(
{ scaffoldBackdrop -> {
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) } var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton( StyledIconButton(
onClick = { onClick = {
@@ -140,7 +146,7 @@ fun HeadTrackingScreen() {
}, },
icon = if (isActive) "􀊅" else "􀊃", icon = if (isActive) "􀊅" else "􀊃",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = backdrop
) )
} }
), ),
@@ -223,10 +229,9 @@ fun HeadTrackingScreen() {
} }
} }
} }
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton( StyledButton(
onClick = { onClick = {
gestureText = gestureTextValue gestureText = "Shake your head or nod!"
coroutineScope.launch { coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
@@ -745,5 +750,5 @@ private fun AccelerationPlot() {
@Preview @Preview
@Composable @Composable
fun HeadTrackingScreenPreview() { fun HeadTrackingScreenPreview() {
HeadTrackingScreen() HeadTrackingScreen(navController = NavController(LocalContext.current))
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -33,7 +33,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -47,22 +46,26 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private var debounceJob: Job? = null
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -70,7 +73,7 @@ private const val TAG = "HearingAidAdjustments"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
@@ -78,7 +81,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments) title = stringResource(R.string.adjustments),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -96,10 +107,13 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) } val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) } val eq = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) } val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) } val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) } val initialReadSucceeded = remember { mutableStateOf(false) }
@@ -108,8 +122,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val hearingAidSettings = remember { val hearingAidSettings = remember {
mutableStateOf( mutableStateOf(
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = eq.value,
rightEQ = rightEQ.value, rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
@@ -154,8 +168,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
toneSliderValue.floatValue = parsed.leftTone toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf() eq.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
@@ -190,8 +203,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = eq.value,
rightEQ = rightEQ.value, rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
@@ -205,7 +218,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(attManager, hearingAidSettings.value)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -214,6 +227,26 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
attManager.enableNotifications(ATTHandles.HEARING_AID) attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener) attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
phoneEQEnabled.value = aacpManager.eqOnPhone
mediaEQEnabled.value = aacpManager.eqOnMedia
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: HearingAidSettings? = null var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
@@ -239,8 +272,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
toneSliderValue.floatValue = parsedSettings.leftTone toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
leftEQ.value = parsedSettings.leftEQ.copyOf() eq.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
@@ -318,3 +350,150 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
} }
} }
private data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
private fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -63,6 +63,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
@@ -82,6 +83,7 @@ fun HearingAidScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
@@ -97,12 +99,19 @@ fun HearingAidScreen(navController: NavController) {
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_aid), title = stringResource(R.string.hearing_aid),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = emptyList(),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerHeight, hazeState -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -112,7 +121,6 @@ fun HearingAidScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember { val hearingAidListener = remember {
@@ -128,9 +136,9 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
// val mediaAssistEnabled = remember { mutableStateOf(false) } val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) } val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) } val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
@@ -155,13 +163,13 @@ fun HearingAidScreen(navController: NavController) {
initialLoad.value = false initialLoad.value = false
} }
// fun onAdjustPhoneChange(value: Boolean) { fun onAdjustPhoneChange(value: Boolean) {
// // TODO // TODO
// } }
// fun onAdjustMediaChange(value: Boolean) { fun onAdjustMediaChange(value: Boolean) {
// // TODO // TODO
// } }
Text( Text(
text = stringResource(R.string.hearing_aid), text = stringResource(R.string.hearing_aid),
@@ -214,13 +222,6 @@ fun HearingAidScreen(navController: NavController) {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test),
navController,
independent = true
)
// not implemented yet // not implemented yet
// StyledToggle( // StyledToggle(
@@ -288,7 +289,7 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
}, },
hazeState = hazeStateS.value, hazeState = hazeState,
// backdrop = backdrop // backdrop = backdrop
) )
} }

View File

@@ -1,90 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingProtectionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
Spacer(modifier = Modifier.height(12.dp))
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
)
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -63,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -112,7 +111,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
kotlinx.coroutines.MainScope().launch { kotlinx.coroutines.MainScope().launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val process = Runtime.getRuntime().exec("su -c id") val process = Runtime.getRuntime().exec("/system/bin/su -c id")
val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
rootCheckPassed = (exitValue == 0) rootCheckPassed = (exitValue == 0)
@@ -158,14 +157,14 @@ fun Onboarding(navController: NavController, activityContext: Context) {
StyledScaffold( StyledScaffold(
title = "Setting Up", title = "Setting Up",
actionButtons = listOf( actionButtons = listOf(
{scaffoldBackdrop -> {
StyledIconButton( StyledIconButton(
onClick = { onClick = {
showSkipDialog = true showSkipDialog = true
}, },
icon = "􀊋", icon = "􀊋",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = backdrop
) )
} }
) )
@@ -202,7 +201,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = stringResource(R.string.root_access_required), text = "Root Access Required",
style = TextStyle( style = TextStyle(
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -215,7 +214,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library), text = "This app needs root access to hook onto the Bluetooth library",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -228,7 +227,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
if (rootCheckFailed) { if (rootCheckFailed) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.root_access_denied), text = "Root access was denied. Please grant root permissions.",
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,

View File

@@ -1,93 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.open_source_licenses)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
@@ -110,8 +110,8 @@ fun LongPress(navController: NavController, name: String) {
if (modesByte != null) { if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}") Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
} }
val context = LocalContext.current val context = LocalContext.current
@@ -122,7 +122,15 @@ fun LongPress(navController: NavController, name: String) {
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) } var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = name title = name,
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column ( Column (
@@ -186,7 +194,7 @@ fun LongPress(navController: NavController, name: String) {
listeningModeItems.add( listeningModeItems.add(
SelectItem( SelectItem(
name = stringResource(R.string.off), name = stringResource(R.string.off),
description = stringResource(R.string.listening_mode_off_description), description = "Turns off noise management",
iconRes = R.drawable.noise_cancellation, iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0, selected = (currentByte and 0x01) != 0,
onClick = { onClick = {
@@ -212,11 +220,11 @@ fun LongPress(navController: NavController, name: String) {
listeningModeItems.addAll(listOf( listeningModeItems.addAll(listOf(
SelectItem( SelectItem(
name = stringResource(R.string.transparency), name = stringResource(R.string.transparency),
description = stringResource(R.string.listening_mode_transparency_description), description = "Lets in external sounds",
iconRes = R.drawable.transparency, iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0, selected = (currentByte and 0x02) != 0,
onClick = { onClick = {
val bit = 0x04 val bit = 0x02
val newValue = if ((currentByte and bit) != 0) { val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv() val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte if (countEnabledModes(temp) >= 2) temp else currentByte
@@ -235,7 +243,7 @@ fun LongPress(navController: NavController, name: String) {
), ),
SelectItem( SelectItem(
name = stringResource(R.string.adaptive), name = stringResource(R.string.adaptive),
description = stringResource(R.string.listening_mode_adaptive_description), description = "Dynamically adjust external noise",
iconRes = R.drawable.adaptive, iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0, selected = (currentByte and 0x08) != 0,
onClick = { onClick = {
@@ -258,11 +266,11 @@ fun LongPress(navController: NavController, name: String) {
), ),
SelectItem( SelectItem(
name = stringResource(R.string.noise_cancellation), name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_noise_cancellation_description), description = "Blocks out external sounds",
iconRes = R.drawable.noise_cancellation, iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0, selected = (currentByte and 0x04) != 0,
onClick = { onClick = {
val bit = 0x02 val bit = 0x04
val newValue = if ((currentByte and bit) != 0) { val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv() val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte if (countEnabledModes(temp) >= 2) temp else currentByte

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -87,6 +87,14 @@ fun RenameScreen(navController: NavController) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.name), title = stringResource(R.string.name),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -100,7 +100,15 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_transparency_mode) title = stringResource(R.string.customize_transparency_mode),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -94,6 +94,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
@@ -215,7 +216,15 @@ fun TroubleshootingScreen(navController: NavController) {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.troubleshooting) title = stringResource(R.string.troubleshooting),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -369,7 +378,7 @@ fun TroubleshootingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(R.string.troubleshooting_steps), text = "TROUBLESHOOTING STEPS",
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,

View File

@@ -1,347 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
Text(
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textAlign = TextAlign.Center
)
return
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
) { spacerHeight, hazeState ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(spacerHeight))
Text(
text = stringResource(R.string.hearing_test_value_instruction),
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
)
val tone = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.value,
leftTone = tone.value,
rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.value
)
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.value = parsed.leftTone
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsed.ownVoiceAmplification
leftAmplification.value = parsed.leftAmplification
rightAmplification.value = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.value,
leftTone = tone.value,
rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.value
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.value = parsedSettings.leftTone
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
leftAmplification.value = parsedSettings.leftAmplification
rightAmplification.value = parsedSettings.rightAmplification
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(60.dp))
Text(
text = stringResource(R.string.left),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.right),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
}
frequencies.forEachIndexed { index, freq ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = freq,
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically),
textAlign = TextAlign.End,
style = TextStyle(
color = textColor,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
OutlinedTextField(
value = leftEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = leftEQ.value.copyOf()
newArray[index] = parsed
leftEQ.value = newArray
Log.d(TAG, "Left EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = rightEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = rightEQ.value.copyOf()
newArray[index] = parsed
rightEQ.value = newArray
Log.d(TAG, "Right EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
}
}
}
}
}

View File

@@ -1,192 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun VersionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.version),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 1",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version1 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 2",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version2 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 3",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version3 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
@@ -89,12 +89,10 @@ import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.BluetoothConnectionManager
//import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevice
//import me.kavishdevar.librepods.utils.CrossDevicePackets import me.kavishdevar.librepods.utils.CrossDevicePackets
import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.utils.IslandType import me.kavishdevar.librepods.utils.IslandType
@@ -154,7 +152,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var localMac = "" var localMac = ""
lateinit var aacpManager: AACPManager lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null var attManager: ATTManager? = null
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false var cameraActive = false
private var disconnectedBecauseReversed = false private var disconnectedBecauseReversed = false
private var otherDeviceTookOver = false private var otherDeviceTookOver = false
@@ -167,6 +164,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var headGestures: Boolean = true, var headGestures: Boolean = true,
var disconnectWhenNotWearing: Boolean = false, var disconnectWhenNotWearing: Boolean = false,
var conversationalAwarenessVolume: Int = 43, var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
var qsClickBehavior: String = "cycle", var qsClickBehavior: String = "cycle",
var bleOnlyMode: Boolean = false, var bleOnlyMode: Boolean = false,
@@ -192,23 +190,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var cameraAction: StemPressType? = null, var cameraAction: AACPManager.Companion.StemPressType? = null,
// AirPods device information
var airpodsName: String = "",
var airpodsModelNumber: String = "",
var airpodsManufacturer: String = "",
var airpodsSerialNumber: String = "",
var airpodsLeftSerialNumber: String = "",
var airpodsRightSerialNumber: String = "",
var airpodsVersion1: String = "",
var airpodsVersion2: String = "",
var airpodsVersion3: String = "",
var airpodsHardwareRevision: String = "",
var airpodsUpdaterIdentifier: String = "",
// phone's mac, needed for tipi
var selfMacAddress: String = ""
) )
private lateinit var config: ServiceConfig private lateinit var config: ServiceConfig
@@ -231,9 +213,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var handleIncomingCallOnceConnected = false private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
private val bleStatusListener = object : BLEManager.AirPodsStatusListener { private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi") @SuppressLint("NewApi")
override fun onDeviceStatusChanged( override fun onDeviceStatusChanged(
@@ -370,29 +349,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this) sharedPreferences.registerOnSharedPreferenceChangeListener(this)
localMac = config.selfMacAddress val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "settings", "get", "secure", "bluetooth_address"))
if (localMac.isEmpty()) { val output = process.inputStream.bufferedReader().use { it.readLine() }
localMac = try { localMac = output.trim()
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address")
)
val exitCode = process.waitFor()
if (exitCode == 0) {
process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
} else {
""
}
} catch (e: Exception) {
Log.e(TAG, "Error retrieving local MAC address: ${e.message}. We probably aren't rooted.")
""
}
config.selfMacAddress = localMac
sharedPreferences.edit {
putString("self_mac_address", localMac)
}
}
ServiceManager.setService(this) ServiceManager.setService(this)
startForegroundNotification() startForegroundNotification()
@@ -475,6 +434,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
43 43
) )
if (!contains("textColor")) putLong("textColor", -1L)
if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle")
if (!contains("name")) putString("name", "AirPods") if (!contains("name")) putString("name", "AirPods")
@@ -576,11 +537,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
MODE_PRIVATE MODE_PRIVATE
) )
) )
// Log.d(TAG, "Initializing CrossDevice") Log.d(TAG, "Initializing CrossDevice")
// CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// CrossDevice.init(this@AirPodsService) CrossDevice.init(this@AirPodsService)
// Log.d(TAG, "CrossDevice initialized") Log.d(TAG, "CrossDevice initialized")
// } }
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
macAddress = sharedPreferences.getString("mac_address", "") ?: "" macAddress = sharedPreferences.getString("mac_address", "") ?: ""
@@ -593,8 +554,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
when (state) { when (state) {
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
if (leAvailableForAudio) runBlocking {
takeOver("call") takeOver("call")
} }
if (config.headGestures) { if (config.headGestures) {
@@ -604,8 +564,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
TelephonyManager.CALL_STATE_OFFHOOK -> { TelephonyManager.CALL_STATE_OFFHOOK -> {
val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
if (leAvailableForAudio) CoroutineScope(
Dispatchers.IO).launch { Dispatchers.IO).launch {
takeOver("call") takeOver("call")
} }
@@ -663,8 +622,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { putString("name", config.deviceName) } sharedPreferences.edit { putString("name", config.deviceName) }
} }
// Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
// if (!CrossDevice.isAvailable) { if (!CrossDevice.isAvailable) {
Log.d(TAG, "${config.deviceName} connected") Log.d(TAG, "${config.deviceName} connected")
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!) connectToSocket(device!!)
@@ -676,8 +635,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) putString("mac_address", macAddress)
} }
// } }
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null device = null
isConnectedLocally = false isConnectedLocally = false
@@ -742,7 +700,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) { if (connectedDevices.isNotEmpty()) {
// if (!CrossDevice.isAvailable) { if (!CrossDevice.isAvailable) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device) connectToSocket(device)
} }
@@ -751,7 +709,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) putString("mac_address", macAddress)
} }
// } }
this@AirPodsService.sendBroadcast( this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED) Intent(AirPodsNotifications.AIRPODS_CONNECTED)
) )
@@ -768,9 +726,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
} }
// if (!isConnectedLocally && !CrossDevice.isAvailable) { if (!isConnectedLocally && !CrossDevice.isAvailable) {
// clearPacketLogs() clearPacketLogs()
// } }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
bleManager.startScanning() bleManager.startScanning()
@@ -842,8 +800,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.getString("name", device?.name), .getString("name", device?.name),
batteryNotification.getBattery() batteryNotification.getBattery()
) )
// CrossDevice.sendRemotePacket(batteryInfo) CrossDevice.sendRemotePacket(batteryInfo)
// CrossDevice.batteryBytes = batteryInfo CrossDevice.batteryBytes = batteryInfo
for (battery in batteryNotification.getBattery()) { for (battery in batteryNotification.getBattery()) {
Log.d( Log.d(
@@ -968,54 +926,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
} }
override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) { override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) {
Log.d(
"AirPodsParser",
"Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}"
)
// Store in SharedPreferences
sharedPreferences.edit {
putString("airpods_name", deviceInformation.name)
putString("airpods_model_number", deviceInformation.modelNumber)
putString("airpods_manufacturer", deviceInformation.manufacturer)
putString("airpods_serial_number", deviceInformation.serialNumber)
putString("airpods_left_serial_number", deviceInformation.leftSerialNumber)
putString("airpods_right_serial_number", deviceInformation.rightSerialNumber)
putString("airpods_version1", deviceInformation.version1)
putString("airpods_version2", deviceInformation.version2)
putString("airpods_version3", deviceInformation.version3)
putString("airpods_hardware_revision", deviceInformation.hardwareRevision)
putString("airpods_updater_identifier", deviceInformation.updaterIdentifier)
}
// Update config
config.airpodsName = deviceInformation.name
config.airpodsModelNumber = deviceInformation.modelNumber
config.airpodsManufacturer = deviceInformation.manufacturer
config.airpodsSerialNumber = deviceInformation.serialNumber
config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber
config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber
config.airpodsVersion1 = deviceInformation.version1
config.airpodsVersion2 = deviceInformation.version2
config.airpodsVersion3 = deviceInformation.version3
config.airpodsHardwareRevision = deviceInformation.hardwareRevision
config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")
@@ -1042,7 +954,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}") Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}")
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "input keyevent 27"))
} else { } else {
val action = getActionFor(bud, stemPressType) val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action") Log.d("AirPodsParser", "$bud $stemPressType action: $action")
@@ -1058,9 +970,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
byteArrayOf(0x00) byteArrayOf(0x00)
) )
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
// MediaController.pausedForOtherDevice = false MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
} }
} }
@@ -1226,6 +1137,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
headGestures = sharedPreferences.getBoolean("head_gestures", true), headGestures = sharedPreferences.getBoolean("head_gestures", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
// AirPods state-based takeover // AirPods state-based takeover
@@ -1251,22 +1163,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!, leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!, rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!,
cameraAction = sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }, cameraAction = sharedPreferences.getString("camera_action", null)?.let { AACPManager.Companion.StemPressType.valueOf(it) },
// AirPods device information
airpodsName = sharedPreferences.getString("airpods_name", "") ?: "",
airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "",
airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "",
airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "",
airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") ?: "",
airpodsRightSerialNumber = sharedPreferences.getString("airpods_right_serial_number", "") ?: "",
airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "",
airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "",
airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "",
airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") ?: "",
airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") ?: "",
selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: ""
) )
} }
@@ -1275,7 +1172,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
when(key) { when(key) {
"name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods"
"mac_address" -> macAddress = preferences.getString(key, "") ?: ""
"automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true) "automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true)
"conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false) "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false)
"show_phone_battery_in_widget" -> { "show_phone_battery_in_widget" -> {
@@ -1287,6 +1183,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"head_gestures" -> config.headGestures = preferences.getBoolean(key, true) "head_gestures" -> config.headGestures = preferences.getBoolean(key, true)
"disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false) "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false)
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43) "conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle" "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
// AirPods state-based takeover // AirPods state-based takeover
@@ -1347,22 +1244,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)!! )!!
setupStemActions() setupStemActions()
} }
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { StemPressType.valueOf(it) } "camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }
}
// AirPods device information if (key == "mac_address") {
"airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: "" macAddress = preferences.getString(key, "") ?: ""
"airpods_model_number" -> config.airpodsModelNumber = preferences.getString(key, "") ?: ""
"airpods_manufacturer" -> config.airpodsManufacturer = preferences.getString(key, "") ?: ""
"airpods_serial_number" -> config.airpodsSerialNumber = preferences.getString(key, "") ?: ""
"airpods_left_serial_number" -> config.airpodsLeftSerialNumber = preferences.getString(key, "") ?: ""
"airpods_right_serial_number" -> config.airpodsRightSerialNumber = preferences.getString(key, "") ?: ""
"airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: ""
"airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: ""
"airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: ""
"airpods_hardware_revision" -> config.airpodsHardwareRevision = preferences.getString(key, "") ?: ""
"airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = preferences.getString(key, "") ?: ""
"self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: ""
} }
} }
@@ -1868,6 +1754,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification) notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
} }
} }
@@ -2078,17 +1965,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun setMetadatas(d: BluetoothDevice) { private fun setMetadatas(d: BluetoothDevice) {
d.let{ device -> d.let{ device ->
val instance = airpodsInstance
if (instance != null) {
val metadataSet = SystemApisUtils.setMetadata( val metadataSet = SystemApisUtils.setMetadata(
device, device,
device.METADATA_MAIN_ICON, device.METADATA_MAIN_ICON,
resToUri(instance.model.budCaseRes).toString().toByteArray() resToUri(R.drawable.pro_2).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MODEL_NAME, device.METADATA_MODEL_NAME,
instance.model.name.toByteArray() "AirPods Pro (2 Gen.)".toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -2098,27 +1983,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_CASE_ICON, device.METADATA_UNTETHERED_CASE_ICON,
resToUri(instance.model.caseRes).toString().toByteArray() resToUri(R.drawable.pro_2_case).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_RIGHT_ICON, device.METADATA_UNTETHERED_RIGHT_ICON,
resToUri(instance.model.rightBudsRes).toString().toByteArray() resToUri(R.drawable.pro_2_right).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_LEFT_ICON, device.METADATA_UNTETHERED_LEFT_ICON,
resToUri(instance.model.leftBudsRes).toString().toByteArray() resToUri(R.drawable.pro_2_left).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MANUFACTURER_NAME, device.METADATA_MANUFACTURER_NAME,
instance.model.manufacturer.toByteArray() "Apple".toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_COMPANION_APP, device.METADATA_COMPANION_APP,
"me.kavishdevar.librepods".toByteArray() "me.kavisdevar.librepods".toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -2136,9 +2021,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"20".toByteArray() "20".toByteArray()
) )
Log.d(TAG, "Metadata set: $metadataSet") Log.d(TAG, "Metadata set: $metadataSet")
} else {
Log.w(TAG, "AirPods instance is not of type AirPodsInstance, skipping metadata setting")
}
} }
} }
@@ -2160,7 +2042,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val name = context?.getSharedPreferences("settings", MODE_PRIVATE) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name) ?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action") Log.d(TAG, "Received bluetooth connection broadcast")
if (ServiceManager.getService()?.isConnectedLocally == true) {
ServiceManager.getService()?.manuallyCheckForAudioSource()
return
}
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
bluetoothDevice.fetchUuidsWithSdp() bluetoothDevice.fetchUuidsWithSdp()
@@ -2195,6 +2081,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return START_STICKY return START_STICKY
} }
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive()
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
Log.d(
TAG,
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
)
disconnectAudio(this, device, shouldResume = shouldResume)
}
}
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission", "HardwareIds") @SuppressLint("MissingPermission", "HardwareIds")
fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) { fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) {
@@ -2270,19 +2169,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return return
} }
// if (CrossDevice.isAvailable) { if (CrossDevice.isAvailable) {
// Log.d(TAG, "CrossDevice is available, continuing") Log.d(TAG, "CrossDevice is available, continuing")
// } }
// else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) {
// Log.d(TAG, "At least one AirPod is in ear, continuing") Log.d(TAG, "At least one AirPod is in ear, continuing")
// } }
// else { else {
// Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping")
// return
// }
if (bleManager.getMostRecentStatus()?.isLeftInEar == false && bleManager.getMostRecentStatus()?.isRightInEar == false) {
Log.d(TAG, "Both AirPods are out of ear, not taking over audio")
return return
} }
@@ -2321,10 +2215,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
Log.d(TAG, "Taking over audio") Log.d(TAG, "Taking over audio")
// CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d(TAG, macAddress) Log.d(TAG, macAddress)
// sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) }
device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress it.address == macAddress
} }
@@ -2349,7 +2243,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER) IslandType.TAKING_OVER)
// CrossDevice.isAvailable = false CrossDevice.isAvailable = false
} }
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
@@ -2390,16 +2284,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) { fun connectToSocket(device: BluetoothDevice) {
Log.d(TAG, "<LogCollector:Start> Connecting to socket") Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (!isConnectedLocally) { if (!isConnectedLocally && !CrossDevice.isAvailable) {
socket = try { socket = try {
createBluetoothSocket(device, uuid) createBluetoothSocket(device, uuid)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}")
return return
} }
@@ -2416,26 +2310,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
attManager = ATTManager(device) attManager = ATTManager(device)
attManager!!.connect() attManager!!.connect()
// Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
}
updateNotificationContent( updateNotificationContent(
true, true,
config.deviceName, config.deviceName,
@@ -2443,29 +2317,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected") Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}") Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
if (manual) { showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
sendToast( throw e
"Couldn't connect to socket: ${e.localizedMessage}"
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
}
} }
} }
if (!socket.isConnected) { if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected") Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
if (manual) { showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
sendToast(
"Couldn't connect to socket: timeout."
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
} }
return
} }
this@AirPodsService.device = device this@AirPodsService.device = device
socket.let { socket.let {
@@ -2512,7 +2372,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}) })
val bytes = buffer.copyOfRange(0, bytesRead) val bytes = buffer.copyOfRange(0, bytesRead)
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
// CrossDevice.sendReceivedPacket(bytes) CrossDevice.sendReceivedPacket(bytes)
updateNotificationContent( updateNotificationContent(
true, true,
sharedPreferences.getString("name", device.name), sharedPreferences.getString("name", device.name),
@@ -2545,17 +2405,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}") Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}")
isConnectedLocally = false isConnectedLocally = false
this@AirPodsService.device = device this@AirPodsService.device = device
updateNotificationContent(false) updateNotificationContent(false)
} }
} else {
Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
} }
} }
fun disconnectForCD() { fun disconnect() {
if (!this::socket.isInitialized) return if (!this::socket.isInitialized) return
socket.close() socket.close()
MediaController.pausedWhileTakingOver = false MediaController.pausedWhileTakingOver = false
@@ -2577,34 +2435,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
isConnectedLocally = false isConnectedLocally = false
// CrossDevice.isAvailable = true CrossDevice.isAvailable = true
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
Log.d(TAG, "Disconnected AirPods upon user request")
} }
val earDetectionNotification = AirPodsNotifications.EarDetection() val earDetectionNotification = AirPodsNotifications.EarDetection()
@@ -2622,20 +2453,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun getBattery(): List<Battery> { fun getBattery(): List<Battery> {
// if (!isConnectedLocally && CrossDevice.isAvailable) { if (!isConnectedLocally && CrossDevice.isAvailable) {
// batteryNotification.setBattery(CrossDevice.batteryBytes) batteryNotification.setBattery(CrossDevice.batteryBytes)
// } }
return batteryNotification.getBattery() return batteryNotification.getBattery()
} }
fun getANC(): Int { fun getANC(): Int {
// if (!isConnectedLocally && CrossDevice.isAvailable) { if (!isConnectedLocally && CrossDevice.isAvailable) {
// ancNotification.setStatus(CrossDevice.ancBytes) ancNotification.setStatus(CrossDevice.ancBytes)
// } }
return ancNotification.status return ancNotification.status
} }
fun disconnectAudio(context: Context, device: BluetoothDevice?) { fun disconnectAudio(context: Context, device: BluetoothDevice?, shouldResume: Boolean = false) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
@@ -2646,8 +2477,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return return
} }
val method = val method =
proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device, 0) method.invoke(proxy, device)
if (shouldResume) {
Handler(Looper.getMainLooper()).postDelayed({
MediaController.sendPlay()
}, 150)
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2664,8 +2500,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.HEADSET) { if (profile == BluetoothProfile.HEADSET) {
try { try {
val method = val method =
proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device, 0) method.invoke(proxy, device)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2685,11 +2521,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
try { try {
val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) val method =
policyMethod.invoke(proxy, device, 100)
val connectMethod =
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
connectMethod.invoke(proxy, device) // reduces the slight delay between allowing and actually connecting method.invoke(proxy, device)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2708,11 +2542,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) { if (profile == BluetoothProfile.HEADSET) {
try { try {
val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) val method =
policyMethod.invoke(proxy, device, 100)
val connectMethod =
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
connectMethod.invoke(proxy, device) method.invoke(proxy, device)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2771,7 +2603,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
isConnectedLocally = false isConnectedLocally = false
// CrossDevice.isAvailable = true CrossDevice.isAvailable = true
super.onDestroy() super.onDestroy()
} }
@@ -2804,19 +2636,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isHeadTrackingActive = false isHeadTrackingActive = false
} }
@SuppressLint("MissingPermission")
fun reconnectFromSavedMac(){
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
device = bluetoothAdapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!, manual = true)
}
}
}
} }
private fun Int.dpToPx(): Int { private fun Int.dpToPx(): Int {

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -42,7 +42,7 @@ class AACPManager {
const val CONTROL_COMMAND: Byte = 0x09 const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06 const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4B const val CONVERSATION_AWARENESS: Byte = 0x4B
const val INFORMATION: Byte = 0x1D const val DEVICE_METADATA: Byte = 0x1D
const val RENAME: Byte = 0x1E const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17 const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
@@ -118,9 +118,7 @@ class AACPManager {
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A), EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20), AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06), OWNS_CONNECTION(0x06);
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38);
companion object { companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? = fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -183,20 +181,6 @@ class AACPManager {
val info2: Byte, val info2: Byte,
var type: String? var type: String?
) )
data class AirPodsInformation(
val name: String,
val modelNumber: String,
val manufacturer: String,
val serialNumber: String,
val version1: String,
val version2: String,
val hardwareRevision: String,
val updaterIdentifier: String,
val leftSerialNumber: String,
val rightSerialNumber: String,
val version3: String
)
} }
var controlCommandStatusList: MutableList<ControlCommandStatus> = var controlCommandStatusList: MutableList<ControlCommandStatus> =
@@ -255,7 +239,7 @@ class AACPManager {
fun onEarDetectionReceived(earDetection: ByteArray) fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray) fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray) fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceInformationReceived(deviceInformation: AirPodsInformation) fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
fun onHeadTrackingReceived(headTracking: ByteArray) fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray) fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray) fun onProximityKeysReceived(proximityKeys: ByteArray)
@@ -497,6 +481,10 @@ class AACPManager {
callback?.onConversationAwarenessReceived(packet) callback?.onConversationAwarenessReceived(packet)
} }
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
@@ -597,13 +585,7 @@ class AACPManager {
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
} }
Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information)
}
else -> { else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -782,9 +764,7 @@ class AACPManager {
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes") throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
} }
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $targetMacAddress") Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
@@ -824,9 +804,7 @@ class AACPManager {
fun sendHijackRequest(selfMacAddress: String): Boolean { fun sendHijackRequest(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes") throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
var success = false var success = false
for (connectedDevice in connectedDevices) { for (connectedDevice in connectedDevices) {
@@ -867,9 +845,7 @@ class AACPManager {
fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean { fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes") throw IllegalArgumentException("MAC address must be 6 bytes")
Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
Log.d(TAG, "SELFMAC: $selfMacAddress") Log.d(TAG, "SELFMAC: $selfMacAddress")
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -928,9 +904,7 @@ class AACPManager {
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes") throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -1006,9 +980,7 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
// throw IllegalArgumentException("MAC address must be 6 bytes") throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
} }
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress)) return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress))
@@ -1201,8 +1173,7 @@ class AACPManager {
var offset = 9 var offset = 9
for (i in 0 until deviceCount) { for (i in 0 until deviceCount) {
if (offset + 8 > data.size) { if (offset + 8 > data.size) {
Log.w(TAG, "Data array too short to parse all connected devices, returning what we have") throw IllegalArgumentException("Data array too short to parse all connected devices")
break
} }
val macBytes = data.sliceArray(offset until offset + 6) val macBytes = data.sliceArray(offset until offset + 6)
val mac = macBytes.joinToString(":") { "%02X".format(it) } val mac = macBytes.joinToString(":") { "%02X".format(it) }
@@ -1237,39 +1208,4 @@ class AACPManager {
connectedDevices = listOf() connectedDevices = listOf()
audioSource = null audioSource = null
} }
fun parseInformationPacket(packet: ByteArray): AirPodsInformation {
val data = packet.sliceArray(6 until packet.size)
var index = 0
while (index < data.size && data[index] != 0x00.toByte()) index++
val strings = mutableListOf<String>()
while (index < data.size) {
// skip 0x00 bytes
while (index < data.size && data[index] == 0x00.toByte()) index++
if (index >= data.size) break
val start = index
// find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++
val str = data.sliceArray(start until index).decodeToString()
strings.add(str)
}
strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string
return AirPodsInformation(
name = strings.getOrNull(0) ?: "",
modelNumber = strings.getOrNull(1) ?: "",
manufacturer = strings.getOrNull(2) ?: "",
serialNumber = strings.getOrNull(3) ?: "",
version1 = strings.getOrNull(4) ?: "",
version2 = strings.getOrNull(5) ?: "",
hardwareRevision = strings.getOrNull(6) ?: "",
updaterIdentifier = strings.getOrNull(7) ?: "",
leftSerialNumber = strings.getOrNull(8) ?: "",
rightSerialNumber = strings.getOrNull(9) ?: "",
version3 = strings.getOrNull(10) ?: "",
)
}
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented /* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
* what is necessary for LibrePods to function, i.e. reading and writing characteristics, * what is necessary for LibrePods to function, i.e. reading and writing characteristics,

View File

@@ -1,235 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
budCaseRes = R.drawable.airpods_1,
budsRes = R.drawable.airpods_1_buds,
leftBudsRes = R.drawable.airpods_1_left,
rightBudsRes = R.drawable.airpods_1_right,
caseRes = R.drawable.airpods_1_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
budCaseRes = R.drawable.airpods_2,
budsRes = R.drawable.airpods_2_buds,
leftBudsRes = R.drawable.airpods_2_left,
rightBudsRes = R.drawable.airpods_2_right,
caseRes = R.drawable.airpods_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
budCaseRes = R.drawable.airpods_3,
budsRes = R.drawable.airpods_3_buds,
leftBudsRes = R.drawable.airpods_3_left,
rightBudsRes = R.drawable.airpods_3_right,
caseRes = R.drawable.airpods_3_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_1,
budsRes = R.drawable.airpods_pro_1_buds,
leftBudsRes = R.drawable.airpods_pro_1_left,
rightBudsRes = R.drawable.airpods_pro_1_right,
caseRes = R.drawable.airpods_pro_1_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_3,
budsRes = R.drawable.airpods_pro_3_buds,
leftBudsRes = R.drawable.airpods_pro_3_left,
rightBudsRes = R.drawable.airpods_pro_3_right,
caseRes = R.drawable.airpods_pro_3_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apple's ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -390,7 +390,6 @@ class BLEManager(private val context: Context) {
private fun cleanupStaleDevices() { private fun cleanupStaleDevices() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
val hadDevices = deviceStatusMap.isNotEmpty()
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff } val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
@@ -399,7 +398,7 @@ class BLEManager(private val context: Context) {
Log.d(TAG, "Removed stale device from tracking: ${device.key}") Log.d(TAG, "Removed stale device from tracking: ${device.key}")
} }
if (hadDevices && deviceStatusMap.isEmpty()) { if (deviceStatusMap.isEmpty()) {
airPodsStatusListener?.onDeviceDisappeared() airPodsStatusListener?.onDeviceDisappeared()
} }
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apple's ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods Contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -200,7 +200,7 @@ object CrossDevice {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!) notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnectForCD() ServiceManager.getService()?.disconnect()
disconnectionRequested = true disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
delay(1000) delay(1000)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("PrivatePropertyName") @file:Suppress("PrivatePropertyName")

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,190 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.util.Log
import androidx.compose.runtime.MutableState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
private const val TAG = "HearingAidUtils"
data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left EQ
for (i in 0..7) {
buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i])
}
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right EQ
for (i in 0..7) {
buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i])
}
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -201,7 +201,7 @@ class LogCollector(private val context: Context) {
private suspend fun executeRootCommand(command: String): String { private suspend fun executeRootCommand(command: String): String {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val process = Runtime.getRuntime().exec("su -c $command") val process = Runtime.getRuntime().exec("/system/bin/su -c $command")
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = StringBuilder() val output = StringBuilder()
var line: String? var line: String?

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -196,7 +196,7 @@ object MediaController {
} }
} }
lastKnownIsMusicActive = hasNewMusicOrMovie && isActive lastKnownIsMusicActive = isActive
} }
} }

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -74,7 +74,7 @@ class RadareOffsetFinder(context: Context) {
fun clearHookOffsets(): Boolean { fun clearHookOffsets(): Boolean {
try { try {
val process = Runtime.getRuntime().exec(arrayOf( val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/su", "-c",
"/system/bin/setprop $HOOK_OFFSET_PROP '' && " + "/system/bin/setprop $HOOK_OFFSET_PROP '' && " +
"/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " + "/system/bin/setprop $CFG_REQ_OFFSET_PROP '' && " +
"/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " + "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP '' && " +
@@ -98,7 +98,7 @@ class RadareOffsetFinder(context: Context) {
fun clearSdpOffset(): Boolean { fun clearSdpOffset(): Boolean {
try { try {
val process = Runtime.getRuntime().exec(arrayOf( val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''" "/system/bin/su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP ''"
)) ))
val exitCode = process.waitFor() val exitCode = process.waitFor()
@@ -115,11 +115,6 @@ class RadareOffsetFinder(context: Context) {
} }
fun isSdpOffsetAvailable(): Boolean { fun isSdpOffsetAvailable(): Boolean {
val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy
if (sharedPreferences?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true for SDP offset.")
return true
}
try { try {
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
@@ -293,14 +288,14 @@ class RadareOffsetFinder(context: Context) {
} }
Log.d(TAG, "Removing existing extract directory") 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("/system/bin/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() Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR") Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR")
val process = Runtime.getRuntime().exec( val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR") arrayOf("/system/bin/su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
) )
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
@@ -332,7 +327,7 @@ class RadareOffsetFinder(context: Context) {
private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) { private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) {
try { try {
val checkDirProcess = Runtime.getRuntime().exec( val checkDirProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'") arrayOf("/system/bin/su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
) )
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists" val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
checkDirProcess.waitFor() checkDirProcess.waitFor()
@@ -343,7 +338,7 @@ class RadareOffsetFinder(context: Context) {
} }
val tarProcess = Runtime.getRuntime().exec( val tarProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}") arrayOf("/system/bin/su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
) )
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines() val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
@@ -357,7 +352,7 @@ class RadareOffsetFinder(context: Context) {
} }
val findProcess = Runtime.getRuntime().exec( val findProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort") arrayOf("/system/bin/su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
) )
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines() val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
@@ -375,14 +370,14 @@ class RadareOffsetFinder(context: Context) {
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile" val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
val fileCheckProcess = Runtime.getRuntime().exec( val fileCheckProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'") arrayOf("/system/bin/su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
) )
val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists" val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists"
fileCheckProcess.waitFor() fileCheckProcess.waitFor()
if (!fileExists) { if (!fileExists) {
Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory") 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() Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
return@withContext false return@withContext false
} }
} }
@@ -399,13 +394,13 @@ class RadareOffsetFinder(context: Context) {
try { try {
Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH") Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH")
val chmod1Result = Runtime.getRuntime().exec( val chmod1Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH") arrayOf("/system/bin/su", "-c", "chmod -R 755 $RADARE2_BIN_PATH")
).waitFor() ).waitFor()
Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH") Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH")
val chmod2Result = Runtime.getRuntime().exec( val chmod2Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH") arrayOf("/system/bin/su", "-c", "chmod -R 755 $BUSYBOX_PATH")
).waitFor() ).waitFor()
if (chmod1Result == 0 && chmod2Result == 0) { if (chmod1Result == 0 && chmod2Result == 0) {
@@ -426,8 +421,8 @@ class RadareOffsetFinder(context: Context) {
var offset = 0L var offset = 0L
try { try {
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("/system/bin/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 currentPATH = ProcessBuilder().command("/system/bin/su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
val envSetup = """ val envSetup = """
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
@@ -436,7 +431,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan" val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan"
Log.d(TAG, "Running command: $command") Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream)) val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -489,7 +484,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req" val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req"
Log.d(TAG, "Running command: $command") Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream)) val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -520,7 +515,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) { if (offset > 0L) {
val hexString = "0x${offset.toString(16)}" val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf( Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString" "/system/bin/su", "-c", "/system/bin/setprop $CFG_REQ_OFFSET_PROP $hexString"
)).waitFor() )).waitFor()
Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString") Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString")
} }
@@ -534,7 +529,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config" val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config"
Log.d(TAG, "Running command: $command") Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream)) val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -565,7 +560,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) { if (offset > 0L) {
val hexString = "0x${offset.toString(16)}" val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf( Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString" "/system/bin/su", "-c", "/system/bin/setprop $CSM_CONFIG_OFFSET_PROP $hexString"
)).waitFor() )).waitFor()
Log.d(TAG, "Saved l2c_csm_config offset: $hexString") Log.d(TAG, "Saved l2c_csm_config offset: $hexString")
} }
@@ -579,7 +574,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req" val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req"
Log.d(TAG, "Running command: $command") Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream)) val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -610,7 +605,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) { if (offset > 0L) {
val hexString = "0x${offset.toString(16)}" val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf( Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString" "/system/bin/su", "-c", "/system/bin/setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
)).waitFor() )).waitFor()
Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString") Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString")
} }
@@ -624,7 +619,7 @@ class RadareOffsetFinder(context: Context) {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord" val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord"
Log.d(TAG, "Running command: $command") Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream)) val errorReader = BufferedReader(InputStreamReader(process.errorStream))
@@ -655,7 +650,7 @@ class RadareOffsetFinder(context: Context) {
if (offset > 0L) { if (offset > 0L) {
val hexString = "0x${offset.toString(16)}" val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf( Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString" "/system/bin/su", "-c", "/system/bin/setprop $SDP_OFFSET_PROP $hexString"
)).waitFor() )).waitFor()
Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString") Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString")
} }
@@ -670,7 +665,7 @@ class RadareOffsetFinder(context: Context) {
Log.d(TAG, "Saving offset to system property: $hexString") Log.d(TAG, "Saving offset to system property: $hexString")
val process = Runtime.getRuntime().exec(arrayOf( val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString" "/system/bin/su", "-c", "/system/bin/setprop $HOOK_OFFSET_PROP $hexString"
)) ))
val exitCode = process.waitFor() val exitCode = process.waitFor()
@@ -699,7 +694,7 @@ class RadareOffsetFinder(context: Context) {
private fun cleanupExtractedFiles() { private fun cleanupExtractedFiles() {
try { try {
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor() Runtime.getRuntime().exec(arrayOf("/system/bin/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") Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to cleanup extracted files", e) Log.e(TAG, "Failed to cleanup extracted files", e)
@@ -737,8 +732,8 @@ class RadareOffsetFinder(context: Context) {
return@withContext false return@withContext false
} }
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim() @Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("/system/bin/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 currentPATH = ProcessBuilder().command("/system/bin/su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
val envSetup = """ val envSetup = """
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH" export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH" export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

View File

@@ -1,20 +1,20 @@
/* /*
LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors *
* Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify *
it under the terms of the GNU General Public License as published by * This program is free software: you can redistribute it and/or modify
the Free Software Foundation, either version 3 of the License, or * it under the terms of the GNU Affero General Public License as published
any later version. * by the Free Software Foundation, either version 3 of the License.
*
This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. * GNU Affero General Public License for more details.
*
You should have received a copy of the GNU General Public License * You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

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