mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-29 01:29:49 +00:00
Compare commits
81 Commits
linux-v0.1
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b24ac49e2 | ||
|
|
d2dd722bc7 | ||
|
|
67fc93bde5 | ||
|
|
0b578d62cf | ||
|
|
072b9b4dac | ||
|
|
0af60cd8a9 | ||
|
|
be29a46dab | ||
|
|
7461f7dfb7 | ||
|
|
904c00afce | ||
|
|
6272357d84 | ||
|
|
6ac6700be6 | ||
|
|
113ee0a966 | ||
|
|
d82e4e2427 | ||
|
|
481d5f13cf | ||
|
|
ef221af505 | ||
|
|
c19190f031 | ||
|
|
d0b8574c68 | ||
|
|
294d733e71 | ||
|
|
f6d7e97796 | ||
|
|
ae174bc9ea | ||
|
|
1804e80cba | ||
|
|
0b8bd5a5b8 | ||
|
|
d1d48562d7 | ||
|
|
c84e64e656 | ||
|
|
51739514fa | ||
|
|
8f9a04390a | ||
|
|
2363b80548 | ||
|
|
99616d6a67 | ||
|
|
4f2d11a4b2 | ||
|
|
1f2d70744f | ||
|
|
3c714c2a26 | ||
|
|
7fbbe07320 | ||
|
|
8ef839d4c1 | ||
|
|
af2622b68e | ||
|
|
b81e420398 | ||
|
|
fd33528218 | ||
|
|
339c478564 | ||
|
|
c9dd79bb82 | ||
|
|
5cd7db574a | ||
|
|
e10fe21ba5 | ||
|
|
b4deccff7e | ||
|
|
dcbbb2ce98 | ||
|
|
e5836c9869 | ||
|
|
287163e116 | ||
|
|
a75557d6dc | ||
|
|
0e1f784737 | ||
|
|
f3b1db2513 | ||
|
|
b5f0c32751 | ||
|
|
8088594df5 | ||
|
|
345b7b9051 | ||
|
|
fa30d3c09a | ||
|
|
826e395379 | ||
|
|
574c193a6d | ||
|
|
10bf2fe68c | ||
|
|
e192dc114b | ||
|
|
de82cdd8c2 | ||
|
|
a06c673400 | ||
|
|
a80680ff73 | ||
|
|
36c55169f1 | ||
|
|
93ac06b8e3 | ||
|
|
aa0898a65a | ||
|
|
ecfbcd1c02 | ||
|
|
5231b12c71 | ||
|
|
0123449d80 | ||
|
|
8a5d6087c9 | ||
|
|
f12fe90134 | ||
|
|
8fbdfd879c | ||
|
|
0a608afbe6 | ||
|
|
be362c5079 | ||
|
|
141f1e7604 | ||
|
|
1dbb36a2aa | ||
|
|
938f0d5448 | ||
|
|
e3eab3e31e | ||
|
|
55d1a69b21 | ||
|
|
f4fbcc9e88 | ||
|
|
944195b193 | ||
|
|
e8e1650145 | ||
|
|
4a4494121d | ||
|
|
3696a4e729 | ||
|
|
7356e57878 | ||
|
|
acf2b9bea7 |
@@ -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}]
|
[*.{py,java,r,R,kt,xml,kts,h,hpp,cpp,qml}]
|
||||||
indent_size = 4
|
indent_size = 4
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,10 +1,5 @@
|
|||||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
release
|
||||||
wak.toml
|
|
||||||
log.txt
|
|
||||||
btl2capfix.zip
|
|
||||||
root-module-manual
|
|
||||||
.vscode
|
.vscode
|
||||||
testing.py
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
CMakeLists.txt.user*
|
CMakeLists.txt.user*
|
||||||
|
|
||||||
|
|||||||
@@ -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 start speaking. The packet format is as follows:
|
AirPods send conversational awareness packets when the person wearing them starts 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 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.
|
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.
|
||||||
|
|
||||||
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,20 +403,3 @@ 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 Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published
|
|
||||||
by the Free Software Foundation, either version 3 of the License.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Welcome to LibrePods contributing guide <!-- omit in toc -->
|
|
||||||
|
|
||||||
Thank you for considering a contribution to LibrePods! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
|
|
||||||
|
|
||||||
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful.
|
|
||||||
|
|
||||||
This guide provides an overview of the contribution workflow, from opening an issue to creating and reviewing a pull request (PR).
|
|
||||||
|
|
||||||
## New contributor guide
|
|
||||||
|
|
||||||
To get an overview of the project, read the [README](./README.md). Here are some resources to help you get started with open-source contributions:
|
|
||||||
|
|
||||||
- [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
|
|
||||||
- [Set up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
|
|
||||||
- [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow)
|
|
||||||
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
To navigate our codebase with confidence, see the [README](./README.md) for setup instructions and usage details. We accept various types of contributions, which don’t always require writing code (like translations).
|
|
||||||
|
|
||||||
To develop for the Android App, Android Studio is the preferred IDE. And you can use any IDE for the linux program, it is just python!
|
|
||||||
|
|
||||||
### Issues
|
|
||||||
|
|
||||||
#### Create a new issue
|
|
||||||
|
|
||||||
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/librepods/issues). If no relevant issue exists, open a new one and fill in the details.
|
|
||||||
|
|
||||||
#### Solve an issue
|
|
||||||
|
|
||||||
Browse our [issues list](https://github.com/kavishdevar/librepods/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution.
|
|
||||||
|
|
||||||
### Make Changes
|
|
||||||
|
|
||||||
#### Make changes locally
|
|
||||||
|
|
||||||
1. Fork the repository and clone it to your local environment.
|
|
||||||
```
|
|
||||||
git clone https://github.com/kavishdevar/librepods.git
|
|
||||||
cd AirPods-Like-Normal
|
|
||||||
```
|
|
||||||
2. Create a working branch to start your changes.
|
|
||||||
```
|
|
||||||
git checkout -b your-feature-branch
|
|
||||||
```
|
|
||||||
3. Make your changes, following the existing style and structure.
|
|
||||||
|
|
||||||
4. Test your changes to ensure they work as expected and do not introduce new issues.
|
|
||||||
|
|
||||||
### Commit your changes
|
|
||||||
|
|
||||||
Commit your changes with a descriptive message.
|
|
||||||
|
|
||||||
### Pull Request
|
|
||||||
|
|
||||||
When your changes are ready, create a pull request (PR):
|
|
||||||
- Fill out the PR template to help reviewers understand your changes.
|
|
||||||
- If your PR is related to an issue, don’t forget to [link your PR to it](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
|
||||||
- Enable the checkbox to allow maintainers to edit your PR, so any required changes can be merged easily.
|
|
||||||
|
|
||||||
Once your PR is open, a team member will review it. They may ask questions or request additional information.
|
|
||||||
|
|
||||||
- If changes are requested, apply them in your fork and commit them to the PR branch.
|
|
||||||
- Mark conversations as resolved as you apply feedback.
|
|
||||||
- For merge conflicts, follow this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to resolve them.
|
|
||||||
|
|
||||||
### Your PR is merged!
|
|
||||||
|
|
||||||
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.
|
|
||||||
147
LICENSE
147
LICENSE
@@ -1,5 +1,5 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 29 June 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,15 +7,17 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
The GNU General Public License is a free, copyleft license for
|
||||||
software and other kinds of works, specifically designed to ensure
|
software and other kinds of works.
|
||||||
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,
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
the GNU General Public License is 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.
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
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
|
||||||
@@ -24,34 +26,44 @@ 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.
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
To protect your rights, we need to prevent others from denying you
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
you this License which gives you legal permission to copy, distribute
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
and/or modify the software.
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
For example, if you distribute copies of such a program, whether
|
||||||
improvements made in alternate versions of the program, if they
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
receive widespread use, become available for other developers to
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
incorporate. Many developers of free software are heartened and
|
or can get the source code. And you must show them these terms so they
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
know their rights.
|
||||||
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.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
ensure that, in such cases, the modified source code becomes available
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
to the community. It requires the operator of a network server to
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
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.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
that there is no warranty for this free software. For both users' and
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
changed, so that their problems will not be attributed erroneously to
|
||||||
this license.
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -60,7 +72,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
"This License" refers to version 3 of the GNU 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.
|
||||||
@@ -537,45 +549,35 @@ 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. Remote Network Interaction; Use with the GNU General Public License.
|
13. Use with the GNU Affero 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 General Public License into a single
|
under version 3 of the GNU Affero 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 work with which it is combined will remain governed by version
|
but the special requirements of the GNU Affero General Public License,
|
||||||
3 of the GNU General Public License.
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
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 Affero General Public License from time to time. Such new versions
|
the GNU General Public License from time to time. Such new versions will
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
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 Affero General
|
Program specifies that a certain numbered version of the GNU 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 Affero General Public License, you may choose any version ever published
|
GNU 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 Affero General Public License can be used, that proxy's
|
versions of the GNU 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.
|
||||||
|
|
||||||
@@ -633,29 +635,40 @@ 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 Affero General Public License as published
|
it under the terms of the GNU General Public License as published by
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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 your software can interact with users remotely through a computer
|
If the program does terminal interaction, make it output a short
|
||||||
network, you should also make sure that it provides a way for users to
|
notice like this when it starts in an interactive mode:
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
<program> Copyright (C) <year> <name of author>
|
||||||
of the code. There are many ways you could offer source, and different
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
solutions will be better for different programs; see section 13 for the
|
This is free software, and you are welcome to redistribute it
|
||||||
specific requirements.
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
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 AGPL, see
|
For more information on this, and how to apply and follow the GNU GPL, 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>.
|
||||||
184
README.md
184
README.md
@@ -1,29 +1,36 @@
|
|||||||

|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" />
|
||||||
|
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" />
|
||||||
|
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
|
||||||
|
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
|
||||||
|
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
|
||||||
|
|
||||||
|
<a href="https://discord.gg/HhG4ycVum4">
|
||||||
|
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
[](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
|
>[!IMPORTANT]
|
||||||
[](https://github.com/kavishdevar/librepods/releases/latest)
|
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||||
[](https://github.com/kavishdevar/librepods/releases)
|
|
||||||
[](https://github.com/kavishdevar/librepods/stargazers)
|
|
||||||
[](https://github.com/kavishdevar/librepods/issues)
|
|
||||||
[](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
|
|
||||||
[](https://github.com/kavishdevar/librepods/graphs/contributors)
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
## 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.
|
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).
|
||||||
|
|
||||||
## Key Features
|
# Key Features
|
||||||
|
|
||||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||||
@@ -36,73 +43,60 @@ 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
|
||||||
- Few accessibility features
|
- All accessibility settings
|
||||||
- And more!
|
- And more!
|
||||||
|
|
||||||
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
|
* Features marked with an asterisk require the VendorID to be change to that of Apple.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
||||||
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
|
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
|
||||||
|
|
||||||
- Battery monitoring
|

|
||||||
- Automatic Ear detection
|
|
||||||
- Conversational Awareness
|
|
||||||
- Switching Noise Control modes
|
|
||||||
- Device renaming
|
|
||||||
|
|
||||||
> [!NOTE]
|
## Android
|
||||||
> Work in progress, but core functionality is stable and usable.
|
|
||||||
|
|
||||||
For installation and detailed info, see the [Linux README](/linux/README.md).
|
### Screenshots
|
||||||
|
|
||||||
### Android
|
| | | |
|
||||||
|
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
#### Screenshots
|
|  |  |  |
|
||||||
|
|  |  |  |
|
||||||
| | | |
|
|  |  |  |
|
||||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
|  |  |  |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
|
|
||||||
here's a very unprofessional demo video
|
here's a very unprofessional demo video
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||||
|
|
||||||
#### Root Requirement
|
### Root Requirement
|
||||||
|
|
||||||
> [!CAUTION]
|
The app needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods.
|
||||||
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
|
|
||||||
>
|
|
||||||
> There are **no exceptions** to the root requirement until Google merges the fix.
|
|
||||||
|
|
||||||
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.
|
[https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
|
||||||
|
|
||||||
## Bluetooth DID (Device Identification) Hook
|
Please do not comment in the thread. The issue has already been resolved and should be available in Android 17 for all devices.
|
||||||
|
|
||||||
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
|
However, if you are using ColorOS/OxygenOS 16, Android 16 QPR3 on Pixel (ensure you're on the latest Play system update), you don't need root for most features.
|
||||||
|
|
||||||
### Multi-device Connectivity
|
> [!IMPORTANT]
|
||||||
|
> This workaround with Xposed is not guaranteed to work on all devices.
|
||||||
|
|
||||||
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
|
Features requiring the VendorID hook will still require root. These features include customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint.
|
||||||
|
|
||||||
### Accessibility Settings and Hearing Aid
|
### Troubleshooting steps for common errors
|
||||||
|
- Ensure the correct scope is set in LSPosed/Vector.
|
||||||
|
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
|
||||||
|
- Restart your phone after confirming the scope.
|
||||||
|
|
||||||
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
### A few notes
|
||||||
|
|
||||||
All hearing aid customizations can be done from Android, including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
|
- 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.
|
||||||
|
|
||||||
To enable these features, enable App Settings -> `act as Apple Device`.
|
|
||||||
|
|
||||||
#### A few notes
|
|
||||||
|
|
||||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced.
|
|
||||||
|
|
||||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
- 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.
|
||||||
|
|
||||||
@@ -110,9 +104,74 @@ To enable these features, enable App Settings -> `act as Apple Device`.
|
|||||||
|
|
||||||
- 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.
|
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
|
||||||
|
|
||||||
## Star History
|
# Changing VendorID in the DID profile to that of Apple
|
||||||
|
|
||||||
[](https://star-history.com/#kavishdevar/librepods&Date)
|
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
|
||||||
|
|
||||||
|
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
|
||||||
|
|
||||||
|
## Accessibility Settings and Hearing Aid
|
||||||
|
|
||||||
|
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
# 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](https://low-skies.hackclub.com)!
|
||||||
|
|
||||||
|
# Alternates for other platforms:
|
||||||
|
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
|
||||||
|
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
|
||||||
|
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
|
||||||
|
|
||||||
|
# Nightly/Development Builds
|
||||||
|
|
||||||
|
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
|
||||||
|
|
||||||
|
### Android
|
||||||
|
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
|
||||||
|
2. Click on the most recent successful workflow run
|
||||||
|
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
|
||||||
|
4. Extract the zip and install the `.apk` on your device
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
|
||||||
|
|
||||||
|
### Linux (Rust)
|
||||||
|
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
|
||||||
|
2. Click on the most recent successful workflow run
|
||||||
|
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Nightly builds are unstable and may contain bugs. Use at your own risk.
|
||||||
|
|
||||||
|
# Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
||||||
|
<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
|
||||||
|
|
||||||
@@ -120,15 +179,16 @@ 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
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU Affero General Public License as published
|
it under the terms of the GNU General Public License as published by
|
||||||
by the Free Software Foundation, either version 3 of the License.
|
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,
|
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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
|
along with this program. 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.
|
||||||
|
|||||||
@@ -1,42 +1,80 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val versionName = "0.2.3"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
|
// alias(libs.plugins.hilt)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val props = Properties().apply {
|
||||||
|
load(rootProject.file("local.properties").inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(props["RELEASE_STORE_FILE"] as String)
|
||||||
|
storePassword = props["RELEASE_STORE_PASSWORD"] as String
|
||||||
|
keyAlias = props["RELEASE_KEY_ALIAS"] as String
|
||||||
|
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
namespace = "me.kavishdevar.librepods"
|
namespace = "me.kavishdevar.librepods"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
minSdk = 28
|
minSdk = 33
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 8
|
versionCode = 38
|
||||||
versionName = "0.2.0"
|
versionName = versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
)
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
create("playRelease") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||||
|
versionNameSuffix = "-play"
|
||||||
|
}
|
||||||
|
create("playDebug") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||||
|
versionNameSuffix = "-youshouldnothavethis"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
}
|
}
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
@@ -46,18 +84,41 @@ android {
|
|||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
res.directories += "src/main/res-apple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ndkVersion = "30.0.14904198"
|
||||||
|
|
||||||
|
flavorDimensions += "env"
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
create("normal") {
|
||||||
|
dimension = "env"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DIS_XPOSED=OFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create("xposed") {
|
||||||
|
dimension = "env"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DIS_XPOSED=ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.hiddenapibypass)
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
@@ -69,21 +130,98 @@ dependencies {
|
|||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(libs.androidx.dynamicanimation)
|
implementation(libs.androidx.dynamicanimation)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.billing)
|
||||||
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)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
implementation(libs.backdrop)
|
||||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
// implementation(libs.hilt)
|
||||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
// implementation(libs.hilt.compiler)
|
||||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
add("xposedCompileOnly", libs.libxposed.api)
|
||||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
add("xposedImplementation", libs.libxposed.service)
|
||||||
|
add("playReleaseImplementation", libs.billing)
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
export{
|
export {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
excludeFields = listOf("generated")
|
excludeFields = listOf("generated")
|
||||||
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val rootModuleDir = rootProject.file("../root-module-manual")
|
||||||
|
val releaseDir = rootProject.file("../release")
|
||||||
|
|
||||||
|
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
fun registerRootModuleZipTask(
|
||||||
|
name: String,
|
||||||
|
flavor: String,
|
||||||
|
buildType: String
|
||||||
|
) = tasks.register<Zip>(name) {
|
||||||
|
|
||||||
|
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
||||||
|
dependsOn(variantTask)
|
||||||
|
|
||||||
|
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
|
||||||
|
|
||||||
|
from(rootModuleDir)
|
||||||
|
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||||
|
|
||||||
|
from(layout.buildDirectory.file(apkPath)) {
|
||||||
|
into("system/priv-app/LibrePods")
|
||||||
|
rename { "LibrePods.apk" }
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveFileName.set("LibrePods-FOSS-v$versionName-$buildType.zip")
|
||||||
|
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val zipRelease = registerRootModuleZipTask(
|
||||||
|
"zipXposedReleaseModule",
|
||||||
|
"xposed",
|
||||||
|
"release"
|
||||||
|
)
|
||||||
|
|
||||||
|
val zipDebug = registerRootModuleZipTask(
|
||||||
|
"zipXposedDebugModule",
|
||||||
|
"xposed",
|
||||||
|
"debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||||
|
|
||||||
|
dependsOn(
|
||||||
|
zipRelease,
|
||||||
|
zipDebug,
|
||||||
|
"bundleXposedPlayRelease"
|
||||||
|
)
|
||||||
|
|
||||||
|
into(releaseDir)
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
|
||||||
|
include("*.apk")
|
||||||
|
rename(".*", "LibrePods-FOSS-v$versionName-release.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
|
||||||
|
include("*.apk")
|
||||||
|
rename(".*", "LibrePods-FOSS-v$versionName-debug.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
|
||||||
|
include("*.aab")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
|
||||||
|
include("*.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("packageReleaseArtifacts") {
|
||||||
|
dependsOn(collect)
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
android/app/proguard-rules.pro
vendored
4
android/app/proguard-rules.pro
vendored
@@ -18,4 +18,6 @@
|
|||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||||
tools:ignore="ForegroundServicesPolicy" />
|
tools:ignore="ForegroundServicesPolicy" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
|
|
||||||
tools:ignore="ProtectedPermissions" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -19,6 +17,15 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MODIFY_PHONE_STATE"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.LOCAL_MAC_ADDRESS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_SCAN"
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
@@ -28,15 +35,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||||
tools:ignore="ScopedStorage" />
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
|
||||||
android:maxSdkVersion="30" />
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".LibrePodsApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@@ -47,10 +54,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LibrePods"
|
android:theme="@style/Theme.LibrePods"
|
||||||
android:description="@string/app_description"
|
android:description="@string/app_description"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute" >
|
||||||
tools:targetApi="31">
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.NoiseControlWidget"
|
android:name=".presentation.widgets.NoiseControlWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -62,7 +68,7 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.BatteryWidget"
|
android:name=".presentation.widgets.BatteryWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -116,17 +122,17 @@
|
|||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service
|
<!-- <service-->
|
||||||
android:name=".services.AppListenerService"
|
<!-- android:name=".services.AppListenerService"-->
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
|
||||||
android:exported="true">
|
<!-- android:exported="true">-->
|
||||||
<intent-filter>
|
<!-- <intent-filter>-->
|
||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
|
||||||
</intent-filter>
|
<!-- </intent-filter>-->
|
||||||
<meta-data
|
<!-- <meta-data-->
|
||||||
android:name="android.accessibilityservice"
|
<!-- android:name="android.accessibilityservice"-->
|
||||||
android:resource="@xml/app_listener_service_config" />
|
<!-- android:resource="@xml/app_listener_service_config" />-->
|
||||||
</service>
|
<!-- </service>-->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.BootReceiver"
|
android:name=".receivers.BootReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|||||||
@@ -3,10 +3,61 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||||||
project("l2c_fcr_hook")
|
project("l2c_fcr_hook")
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
|
||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
option(IS_XPOSED "Build Xposed components" OFF)
|
||||||
l2c_fcr_hook.cpp
|
|
||||||
l2c_fcr_hook.h)
|
|
||||||
|
|
||||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
add_library(bluetooth_socket SHARED
|
||||||
|
bluetooth_socket.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_options(bluetooth_socket PRIVATE
|
||||||
|
-O2
|
||||||
|
-fvisibility=hidden
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_options(bluetooth_socket PRIVATE
|
||||||
|
-Wl,--strip-all
|
||||||
|
-Wl,--gc-sections
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(bluetooth_socket
|
||||||
android
|
android
|
||||||
log)
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
if(IS_XPOSED)
|
||||||
|
|
||||||
|
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||||
|
|
||||||
|
add_library(l2c_fcr_hook SHARED
|
||||||
|
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
|
||||||
|
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc32.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc64.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_sha256.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(l2c_fcr_hook PRIVATE
|
||||||
|
${XPOSED_SRC_DIR}
|
||||||
|
${XPOSED_SRC_DIR}/xz
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||||
|
XZ_DEC_X86
|
||||||
|
XZ_DEC_ARM
|
||||||
|
XZ_DEC_ARMTHUMB
|
||||||
|
XZ_DEC_ARM64
|
||||||
|
XZ_DEC_ANY_CHECK
|
||||||
|
XZ_USE_CRC64
|
||||||
|
XZ_USE_SHA256
|
||||||
|
XZ_DEC_CONCATENATED
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(l2c_fcr_hook
|
||||||
|
android
|
||||||
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
endif()
|
||||||
|
|||||||
68
android/app/src/main/cpp/bluetooth_socket.cpp
Normal file
68
android/app/src/main/cpp/bluetooth_socket.cpp
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static JavaVM* gVm = nullptr;
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
constexpr auto encryptString(const char (&str)[N], char key) {
|
||||||
|
std::array<char, N> encrypted{};
|
||||||
|
for (size_t i = 0; i < N; i++) {
|
||||||
|
encrypted[i] = str[i] ^ key;
|
||||||
|
}
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
|
||||||
|
std::string result(N - 1, '\0');
|
||||||
|
for (size_t i = 0; i < N - 1; i++) {
|
||||||
|
result[i] = encrypted[i] ^ key;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define ENC(str) encryptString(str, 0x47)
|
||||||
|
#define DEC(arr) decryptString(arr, 0x47).c_str()
|
||||||
|
|
||||||
|
__attribute__((visibility("hidden")))
|
||||||
|
static JavaVM* getVm() { return gVm; }
|
||||||
|
|
||||||
|
__attribute__((visibility("default")))
|
||||||
|
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||||
|
gVm = vm;
|
||||||
|
|
||||||
|
auto fn = [](void*) -> void* {
|
||||||
|
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
|
||||||
|
constexpr auto c2 = ENC("getRuntime");
|
||||||
|
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
|
||||||
|
constexpr auto c4 = ENC("setHiddenApiExemptions");
|
||||||
|
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
|
||||||
|
constexpr auto c6 = ENC("java/lang/String");
|
||||||
|
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||||
|
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
||||||
|
|
||||||
|
JNIEnv* env;
|
||||||
|
getVm()->AttachCurrentThread(&env, nullptr);
|
||||||
|
|
||||||
|
jclass vmRuntime = env->FindClass(DEC(c1));
|
||||||
|
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
|
||||||
|
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
|
||||||
|
|
||||||
|
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
|
||||||
|
jobjectArray prefixes = env->NewObjectArray(
|
||||||
|
2, env->FindClass(DEC(c6)), nullptr);
|
||||||
|
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
|
||||||
|
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
|
||||||
|
|
||||||
|
env->CallVoidMethod(runtime, setExemptions, prefixes);
|
||||||
|
getVm()->DetachCurrentThread();
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
pthread_t t;
|
||||||
|
pthread_create(&t, nullptr, fn, nullptr);
|
||||||
|
pthread_join(t, nullptr);
|
||||||
|
return JNI_VERSION_1_6;
|
||||||
|
}
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <sys/system_properties.h>
|
|
||||||
#include "l2c_fcr_hook.h"
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdlib>
|
|
||||||
|
|
||||||
#define LOG_TAG "AirPodsHook"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
static HookFunType hook_func = nullptr;
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
|
||||||
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
typedef struct _BT_HDR {
|
|
||||||
uint16_t event;
|
|
||||||
uint16_t len;
|
|
||||||
uint16_t offset;
|
|
||||||
uint16_t layer_specific;
|
|
||||||
uint8_t data[];
|
|
||||||
} BT_HDR;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint8_t mode;
|
|
||||||
uint8_t tx_win_sz;
|
|
||||||
uint8_t max_transmit;
|
|
||||||
uint16_t rtrans_tout;
|
|
||||||
uint16_t mon_tout;
|
|
||||||
uint16_t mps;
|
|
||||||
} tL2CAP_FCR;
|
|
||||||
|
|
||||||
// Flow spec structure
|
|
||||||
typedef struct {
|
|
||||||
uint8_t qos_present;
|
|
||||||
uint8_t flow_direction;
|
|
||||||
uint8_t service_type;
|
|
||||||
uint32_t token_rate;
|
|
||||||
uint32_t token_bucket_size;
|
|
||||||
uint32_t peak_bandwidth;
|
|
||||||
uint32_t latency;
|
|
||||||
uint32_t delay_variation;
|
|
||||||
} FLOW_SPEC;
|
|
||||||
|
|
||||||
// Configuration info structure
|
|
||||||
typedef struct {
|
|
||||||
uint16_t result;
|
|
||||||
uint16_t mtu_present;
|
|
||||||
uint16_t mtu;
|
|
||||||
uint16_t flush_to_present;
|
|
||||||
uint16_t flush_to;
|
|
||||||
uint16_t qos_present;
|
|
||||||
FLOW_SPEC qos;
|
|
||||||
uint16_t fcr_present;
|
|
||||||
tL2CAP_FCR fcr;
|
|
||||||
uint16_t fcs_present;
|
|
||||||
uint16_t fcs;
|
|
||||||
uint16_t ext_flow_spec_present;
|
|
||||||
FLOW_SPEC ext_flow_spec;
|
|
||||||
} tL2CAP_CFG_INFO;
|
|
||||||
|
|
||||||
// Basic L2CAP link control block
|
|
||||||
typedef struct {
|
|
||||||
bool wait_ack;
|
|
||||||
// Other FCR fields - not needed for our specific hook
|
|
||||||
} tL2C_FCRB;
|
|
||||||
|
|
||||||
// Forward declarations for needed types
|
|
||||||
struct t_l2c_rcb;
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb {
|
|
||||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
|
||||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
|
||||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
|
||||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
|
||||||
uint16_t local_cid; // Local CID
|
|
||||||
uint16_t remote_cid; // Remote CID
|
|
||||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
|
||||||
uint8_t ccb_priority; // Channel priority
|
|
||||||
uint16_t tx_mps; // MPS for outgoing messages
|
|
||||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
|
||||||
// State variables
|
|
||||||
bool in_use; // True when channel active
|
|
||||||
uint8_t chnl_state; // Channel state
|
|
||||||
uint8_t local_id; // Transaction ID for local trans
|
|
||||||
uint8_t remote_id; // Transaction ID for remote
|
|
||||||
uint8_t timer_entry; // Timer entry
|
|
||||||
uint8_t is_flushable; // True if flushable
|
|
||||||
// Configuration variables
|
|
||||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
|
||||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
|
||||||
uint16_t config_done; // Configuration bitmask
|
|
||||||
uint16_t remote_config_rsp_result; // Remote config response result
|
|
||||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
|
||||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
|
||||||
// Additional control fields
|
|
||||||
uint8_t remote_credit_count; // Credits sent to peer
|
|
||||||
tL2C_FCRB fcrb; // FCR info
|
|
||||||
bool ecoc; // Enhanced Credit-based mode
|
|
||||||
} tL2C_CCB;
|
|
||||||
|
|
||||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
|
||||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
|
||||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
|
||||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
|
||||||
|
|
||||||
// Add original pointer for BTA_DmSetLocalDiRecord
|
|
||||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
|
|
||||||
|
|
||||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
|
||||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
|
||||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
|
||||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
|
||||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
|
||||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
|
||||||
original_l2c_csm_config(p_ccb, event, p_data);
|
|
||||||
|
|
||||||
// Check if this happens during CONFIG_RSP event handling
|
|
||||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
|
||||||
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
|
|
||||||
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
|
|
||||||
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replacement function that does nothing
|
|
||||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
|
||||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
|
||||||
// Just return without doing anything
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New loader for SDP hook offset (persist.librepods.sdp_offset)
|
|
||||||
uintptr_t loadSdpOffset() {
|
|
||||||
const char* property_name = "persist.librepods.sdp_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read sdp offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed sdp offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse sdp offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("No sdp offset property present - skipping SDP hook");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
|
|
||||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
|
|
||||||
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
|
|
||||||
if (p_device_info) {
|
|
||||||
p_device_info->vendor = 0x004C;
|
|
||||||
p_device_info->vendor_id_source = 0x0001;
|
|
||||||
}
|
|
||||||
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
|
|
||||||
if (original_BTA_DmSetLocalDiRecord) {
|
|
||||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Original BTA_DmSetLocalDiRecord not available");
|
|
||||||
return BTA_FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
|
||||||
const char* property_name = "persist.librepods.hook_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read hook offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Using hardcoded fallback offset");
|
|
||||||
return 0x00a55e30;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cCsmConfigOffset() {
|
|
||||||
const char* property_name = "persist.librepods.csm_config_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2c_csm_config offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.peer_info_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t getModuleBase(const char *module_name) {
|
|
||||||
FILE *fp;
|
|
||||||
char line[1024];
|
|
||||||
uintptr_t base_addr = 0;
|
|
||||||
|
|
||||||
fp = fopen("/proc/self/maps", "r");
|
|
||||||
if (!fp) {
|
|
||||||
LOGE("Failed to open /proc/self/maps");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), fp)) {
|
|
||||||
if (strstr(line, module_name)) {
|
|
||||||
char *start_addr_str = line;
|
|
||||||
char *end_addr_str = strchr(line, '-');
|
|
||||||
if (end_addr_str) {
|
|
||||||
*end_addr_str = '\0';
|
|
||||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose(fp);
|
|
||||||
return base_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool findAndHookFunction(const char *library_name) {
|
|
||||||
if (!hook_func) {
|
|
||||||
LOGE("Hook function not initialized");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t base_addr = getModuleBase(library_name);
|
|
||||||
if (!base_addr) {
|
|
||||||
LOGE("Failed to get base address of %s", library_name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all offsets from system properties - no hardcoding
|
|
||||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
|
||||||
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
|
|
||||||
uintptr_t sdp_offset = loadSdpOffset();
|
|
||||||
|
|
||||||
bool success = false;
|
|
||||||
|
|
||||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
|
||||||
if (l2c_fcr_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
|
||||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_fcr_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_process_our_cfg_req if offset is available
|
|
||||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
|
||||||
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2c_csm_config if offset is available
|
|
||||||
if (l2c_csm_config_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
|
|
||||||
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_csm_config_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_csm_config, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2c_csm_config");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2c_csm_config hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_send_peer_info_req if offset is available
|
|
||||||
if (l2cu_send_peer_info_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
|
|
||||||
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_send_peer_info_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sdp_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + sdp_offset);
|
|
||||||
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
|
|
||||||
sdp_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
|
||||||
if (strstr(name, "libbluetooth_jni.so")) {
|
|
||||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth JNI library function");
|
|
||||||
}
|
|
||||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
|
||||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth QTI library function");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
|
||||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
|
||||||
LOGI("L2C FCR Hook module initialized");
|
|
||||||
|
|
||||||
hook_func = entries->hook_func;
|
|
||||||
|
|
||||||
return on_library_loaded;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
|
||||||
|
|
||||||
typedef int (*UnhookFunType)(void *func);
|
|
||||||
|
|
||||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint32_t version;
|
|
||||||
HookFunType hook_func;
|
|
||||||
UnhookFunType unhook_func;
|
|
||||||
} NativeAPIEntries;
|
|
||||||
|
|
||||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb tL2C_CCB;
|
|
||||||
typedef struct t_l2c_lcb tL2C_LCB;
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset(const char* package_name);
|
|
||||||
uintptr_t getModuleBase(const char *module_name);
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset();
|
|
||||||
bool findAndHookFunction(const char *library_path);
|
|
||||||
|
|
||||||
#define SDP_MAX_ATTR_LEN 400
|
|
||||||
|
|
||||||
typedef struct t_sdp_di_record {
|
|
||||||
uint16_t vendor;
|
|
||||||
uint16_t vendor_id_source;
|
|
||||||
uint16_t product;
|
|
||||||
uint16_t version;
|
|
||||||
bool primary_record;
|
|
||||||
char client_executable_url[SDP_MAX_ATTR_LEN];
|
|
||||||
char service_description[SDP_MAX_ATTR_LEN];
|
|
||||||
char documentation_url[SDP_MAX_ATTR_LEN];
|
|
||||||
} tSDP_DI_RECORD;
|
|
||||||
|
|
||||||
typedef enum : uint8_t {
|
|
||||||
BTA_SUCCESS = 0, /* Successful operation. */
|
|
||||||
BTA_FAILURE = 1, /* Generic failure. */
|
|
||||||
BTA_PENDING = 2, /* API cannot be completed right now */
|
|
||||||
BTA_BUSY = 3,
|
|
||||||
BTA_NO_RESOURCES = 4,
|
|
||||||
BTA_WRONG_MODE = 5,
|
|
||||||
} tBTA_STATUS;
|
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.screens.Onboarding
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
//import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -27,13 +30,11 @@ 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.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
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
|
||||||
@@ -52,6 +53,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -73,7 +75,6 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -88,7 +89,6 @@ import androidx.compose.ui.draw.scale
|
|||||||
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.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
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.platform.LocalWindowInfo
|
||||||
@@ -103,6 +103,7 @@ 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 androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -112,59 +113,66 @@ 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.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
import me.kavishdevar.librepods.presentation.components.PlayBypassSheet
|
||||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.screens.DebugScreen
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.LongPress
|
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
||||||
import me.kavishdevar.librepods.screens.Onboarding
|
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
||||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||||
import me.kavishdevar.librepods.screens.RenameScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
||||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
||||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
|
||||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
import me.kavishdevar.librepods.presentation.screens.LongPress
|
||||||
import me.kavishdevar.librepods.screens.VersionScreen
|
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.RenameScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.VersionScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.utils.isSupported
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
|
|
||||||
|
//@AndroidEntryPoint
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("l2c_fcr_hook")
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
System.loadLibrary("l2c_fcr_hook")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
LibrePodsTheme {
|
LibrePodsTheme {
|
||||||
getSharedPreferences("settings", MODE_PRIVATE).edit {
|
|
||||||
putLong(
|
|
||||||
"textColor",
|
|
||||||
MaterialTheme.colorScheme.onSurface.toArgb().toLong())}
|
|
||||||
Main()
|
Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -199,82 +207,159 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIncomingIntent(intent: Intent) {
|
|
||||||
val data: Uri? = intent.data
|
|
||||||
|
|
||||||
if (data != null && data.scheme == "librepods") {
|
|
||||||
when (data.host) {
|
|
||||||
"add-magic-keys" -> {
|
|
||||||
val queryParams = data.queryParameterNames
|
|
||||||
queryParams.forEach { param ->
|
|
||||||
val value = data.getQueryParameter(param)
|
|
||||||
Log.d("LibrePods", "Parameter: $param = $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAddMagicKeys(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAddMagicKeys(uri: Uri) {
|
|
||||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
|
|
||||||
val irkHex = uri.getQueryParameter("irk")
|
|
||||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (irkHex != null && validateHexInput(irkHex)) {
|
|
||||||
val irkBytes = hexStringToByteArray(irkHex)
|
|
||||||
val irkBase64 = Base64.encode(irkBytes)
|
|
||||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
|
||||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
|
||||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
|
||||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateHexInput(input: String): Boolean {
|
|
||||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
|
||||||
return hexPattern.matches(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
|
||||||
val result = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
|
||||||
result[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Main() {
|
fun Main() {
|
||||||
val isConnected = remember { mutableStateOf(false) }
|
|
||||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
|
||||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
if (!isSupported(sharedPreferences)) {
|
||||||
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
|
val showPlayBypassVisible = remember { mutableStateOf(false) }
|
||||||
|
val hazeState = rememberHazeState()
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column (
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement
|
||||||
|
.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
val innerBackdrop = rememberLayerBackdrop()
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.layerBackdrop(innerBackdrop),
|
||||||
|
verticalArrangement = Arrangement
|
||||||
|
.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.not_supported),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
DeviceInfoCard()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.check_the_repository_for_more_info),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StyledButton(
|
||||||
|
onClick = { showDialog.value = true },
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bypass_compatibility_check),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmationDialog(
|
||||||
|
showDialog = showDialog,
|
||||||
|
title = stringResource(R.string.bypass_compatibility_check),
|
||||||
|
message = stringResource(R.string.bypass_compatiblity_check_confirmation),
|
||||||
|
confirmText = stringResource(R.string.yes),
|
||||||
|
dismissText = stringResource(R.string.no),
|
||||||
|
onConfirm = {
|
||||||
|
showDialog.value = false
|
||||||
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
|
showPlayBypassVisible.value = true
|
||||||
|
} else {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putBoolean("bypass_device_check.v2", true)
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
showDialog.value = false
|
||||||
|
},
|
||||||
|
hazeState = hazeState
|
||||||
|
)
|
||||||
|
|
||||||
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
|
PlayBypassSheet(
|
||||||
|
visible = showPlayBypassVisible.value,
|
||||||
|
onDismiss = {
|
||||||
|
showPlayBypassVisible.value = false
|
||||||
|
showDialog.value = true
|
||||||
|
},
|
||||||
|
onConfirm = {
|
||||||
|
showPlayBypassVisible.value = false
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putBoolean("bypass_device_check.v2", true)
|
||||||
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backdrop = backdrop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isConnected = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
val overlaySkipped = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
.getBoolean("overlay_permission_skipped", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
listOf(
|
listOf(
|
||||||
@@ -301,23 +386,33 @@ fun Main() {
|
|||||||
val permissionState = rememberMultiplePermissionsState(
|
val permissionState = rememberMultiplePermissionsState(
|
||||||
permissions = allPermissions
|
permissions = allPermissions
|
||||||
)
|
)
|
||||||
|
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
|
|
||||||
|
val airPodsViewModel = remember(airPodsService.value) {
|
||||||
|
airPodsService.value?.let { service ->
|
||||||
|
AirPodsViewModel(
|
||||||
|
service = service,
|
||||||
|
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||||
|
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||||
|
appContext = context.applicationContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
) {
|
||||||
){
|
|
||||||
val backButtonBackdrop = rememberLayerBackdrop()
|
val backButtonBackdrop = rememberLayerBackdrop()
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||||
@@ -325,129 +420,126 @@ fun Main() {
|
|||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
startDestination = "settings",
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { it },
|
initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { -it/4 },
|
targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { -it/4 },
|
initialOffsetX = { -it / 4 },
|
||||||
animationSpec = tween(durationMillis = 300)
|
animationSpec = tween(durationMillis = 300)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
)
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { it },
|
targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
}) {
|
||||||
}
|
|
||||||
) {
|
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
if (airPodsService.value != null) {
|
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
|
||||||
AirPodsSettingsScreen(
|
|
||||||
dev = airPodsService.value?.device,
|
|
||||||
service = airPodsService.value!!,
|
|
||||||
navController = navController,
|
|
||||||
isConnected = isConnected.value,
|
|
||||||
isRemotelyConnected = isRemotelyConnected.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable("debug") {
|
composable("debug") {
|
||||||
DebugScreen(navController = navController)
|
DebugScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("long_press/{bud}") { navBackStackEntry ->
|
composable("long_press/{bud}") { navBackStackEntry ->
|
||||||
LongPress(
|
if (airPodsViewModel != null) LongPress(
|
||||||
navController = navController,
|
viewModel = airPodsViewModel,
|
||||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
name = navBackStackEntry.arguments?.getString("bud")!!,
|
||||||
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("rename") {
|
composable("rename") {
|
||||||
RenameScreen(navController)
|
if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("app_settings") {
|
composable("app_settings") {
|
||||||
AppSettingsScreen(navController)
|
val appSettingsViewModel: AppSettingsViewModel = viewModel()
|
||||||
|
AppSettingsScreen(navController, appSettingsViewModel)
|
||||||
}
|
}
|
||||||
composable("troubleshooting") {
|
composable("troubleshooting") {
|
||||||
TroubleshootingScreen(navController)
|
TroubleshootingScreen(navController)
|
||||||
}
|
}
|
||||||
composable("head_tracking") {
|
composable("head_tracking") {
|
||||||
HeadTrackingScreen(navController)
|
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
|
||||||
}
|
|
||||||
composable("onboarding") {
|
|
||||||
Onboarding(navController, context)
|
|
||||||
}
|
}
|
||||||
composable("accessibility") {
|
composable("accessibility") {
|
||||||
AccessibilitySettingsScreen(navController)
|
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("transparency_customization") {
|
composable("transparency_customization") {
|
||||||
TransparencySettingsScreen(navController)
|
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_aid") {
|
composable("hearing_aid") {
|
||||||
HearingAidScreen(navController)
|
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("hearing_aid_adjustments") {
|
composable("hearing_aid_adjustments") {
|
||||||
HearingAidAdjustmentsScreen(navController)
|
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("adaptive_strength") {
|
composable("adaptive_strength") {
|
||||||
AdaptiveStrengthScreen(navController)
|
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("camera_control") {
|
composable("camera_control") {
|
||||||
CameraControlScreen(navController)
|
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("open_source_licenses") {
|
composable("open_source_licenses") {
|
||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
UpdateHearingTestScreen(navController)
|
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
VersionScreen(navController)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_protection") {
|
composable("hearing_protection") {
|
||||||
HearingProtectionScreen(navController)
|
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
|
||||||
|
}
|
||||||
|
composable("purchase_screen") {
|
||||||
|
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||||
|
PurchaseScreen(purchaseViewModel, navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val showBackButton = remember{ mutableStateOf(false) }
|
val showBackButton = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||||
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
|
showBackButton.value =
|
||||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
destination.route != "settings" // && destination.route != "onboarding"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackButton.value,
|
visible = showBackButton.value,
|
||||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
enter = fadeIn(animationSpec = tween()) + scaleIn(
|
||||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
initialScale = 0f,
|
||||||
|
animationSpec = tween()
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween()) + scaleOut(
|
||||||
|
targetScale = 0.5f,
|
||||||
|
animationSpec = tween(100)
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(
|
.padding(
|
||||||
start = 8.dp,
|
start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = { navController.popBackStack() },
|
onClick = { navController.popBackStack() },
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isSystemInDarkTheme(),
|
backdrop = backButtonBackdrop
|
||||||
backdrop = backButtonBackdrop
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
||||||
|
|
||||||
serviceConnection = remember {
|
serviceConnection = remember {
|
||||||
object : ServiceConnection {
|
object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
@@ -461,17 +553,20 @@ fun Main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(
|
||||||
|
Intent(context, AirPodsService::class.java),
|
||||||
|
serviceConnection,
|
||||||
|
Context.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
|
||||||
if (airPodsService.value?.isConnectedLocally == true) {
|
if (airPodsService.value?.isConnected() == true) {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionsScreen(
|
PermissionsScreen(
|
||||||
permissionState = permissionState,
|
permissionState = permissionState,
|
||||||
canDrawOverlays = canDrawOverlays,
|
canDrawOverlays = canDrawOverlays,
|
||||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,13 +589,9 @@ fun PermissionsScreen(
|
|||||||
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
val pulseScale by infiniteTransition.animateFloat(
|
val pulseScale by infiniteTransition.animateFloat(
|
||||||
initialValue = 1f,
|
initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
|
||||||
targetValue = 1.05f,
|
animation = tween(1000), repeatMode = RepeatMode.Reverse
|
||||||
animationSpec = infiniteRepeatable(
|
), label = "pulse scale"
|
||||||
animation = tween(1000),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulse scale"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -508,18 +599,15 @@ fun PermissionsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp),
|
.height(180.dp), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "\uDBC2\uDEB7",
|
text = "\uDBC2\uDEB7", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 48.sp,
|
fontSize = 48.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -555,29 +643,25 @@ fun PermissionsScreen(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Permission Required",
|
text = "Permission Required", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permissions_required),
|
text = stringResource(R.string.permissions_required), style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor.copy(alpha = 0.7f),
|
color = textColor.copy(alpha = 0.7f),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
@@ -750,8 +834,7 @@ fun PermissionCard(
|
|||||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||||
alpha = 0.15f
|
alpha = 0.15f
|
||||||
)
|
)
|
||||||
),
|
), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
@@ -767,8 +850,7 @@ fun PermissionCard(
|
|||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -777,8 +859,7 @@ fun PermissionCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -795,11 +876,8 @@ fun PermissionCard(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isGranted) "✓" else "!",
|
text = if (isGranted) "✓" else "!", style = TextStyle(
|
||||||
style = TextStyle(
|
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
@@ -85,15 +85,15 @@ 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.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
|
||||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
|
||||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
import me.kavishdevar.librepods.presentation.components.IconAreaSize
|
||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
object BillingManager {
|
||||||
|
lateinit var provider: BillingProvider
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface BillingProvider {
|
||||||
|
val isPremium: StateFlow<Boolean>
|
||||||
|
val price: StateFlow<String>
|
||||||
|
fun purchase(activity: Activity)
|
||||||
|
fun queryPurchases()
|
||||||
|
fun restorePurchases()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.billing
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
|
||||||
|
object BillingProviderFactory {
|
||||||
|
|
||||||
|
fun create(context: Context): BillingProvider {
|
||||||
|
return if (BuildConfig.PLAY_BUILD) {
|
||||||
|
PlayBillingProvider(context)
|
||||||
|
} else {
|
||||||
|
FOSSBillingProvider(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||||
|
private val _isPremium = MutableStateFlow(false)
|
||||||
|
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||||
|
|
||||||
|
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
|
||||||
|
override val price: StateFlow<String> = _price
|
||||||
|
|
||||||
|
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var purchaseJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
queryPurchases()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun purchase(activity: Activity) {
|
||||||
|
activity.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri())
|
||||||
|
)
|
||||||
|
|
||||||
|
purchaseJob?.cancel()
|
||||||
|
|
||||||
|
purchaseJob = scope.launch {
|
||||||
|
delay(5_000)
|
||||||
|
_isPremium.value = true
|
||||||
|
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryPurchases() {
|
||||||
|
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
if (stored != _isPremium.value) {
|
||||||
|
_isPremium.value = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun restorePurchases() {
|
||||||
|
_isPremium.value = true
|
||||||
|
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||||
|
import com.android.billingclient.api.BillingClient
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
|
import com.android.billingclient.api.ProductDetails
|
||||||
|
import com.android.billingclient.api.Purchase
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
|
import com.android.billingclient.api.QueryPurchasesParams
|
||||||
|
import com.android.billingclient.api.acknowledgePurchase
|
||||||
|
import com.android.billingclient.api.queryProductDetails
|
||||||
|
import com.android.billingclient.api.queryPurchasesAsync
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
const val TAG = "PlayBillingProvider"
|
||||||
|
|
||||||
|
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||||
|
|
||||||
|
class PlayBillingProvider(
|
||||||
|
context: Context
|
||||||
|
) : BillingProvider, PurchasesUpdatedListener {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private val _isPremium = MutableStateFlow(false)
|
||||||
|
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||||
|
|
||||||
|
private val _price = MutableStateFlow("unknown")
|
||||||
|
override val price: StateFlow<String> = _price
|
||||||
|
|
||||||
|
|
||||||
|
private var productDetails: ProductDetails? = null
|
||||||
|
|
||||||
|
private val billingClient = BillingClient.newBuilder(context)
|
||||||
|
.setListener(this)
|
||||||
|
.enablePendingPurchases(
|
||||||
|
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
|
override fun onBillingSetupFinished(result: BillingResult) {
|
||||||
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
scope.launch {
|
||||||
|
queryProductDetails()
|
||||||
|
queryExistingPurchases()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBillingServiceDisconnected() {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryProductDetails() {
|
||||||
|
val params = QueryProductDetailsParams.newBuilder()
|
||||||
|
.setProductList(
|
||||||
|
listOf(
|
||||||
|
QueryProductDetailsParams.Product.newBuilder()
|
||||||
|
.setProductId(PREMIUM_PRODUCT_ID)
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.queryProductDetails(params)
|
||||||
|
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
productDetails = result.productDetailsList?.firstOrNull()
|
||||||
|
Log.d(TAG, "Product loaded: ${productDetails?.name}")
|
||||||
|
val priceString = productDetails
|
||||||
|
?.oneTimePurchaseOfferDetails
|
||||||
|
?.formattedPrice
|
||||||
|
|
||||||
|
if (priceString != null) {
|
||||||
|
_price.value = priceString
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryExistingPurchases() {
|
||||||
|
val result = billingClient.queryPurchasesAsync(
|
||||||
|
QueryPurchasesParams.newBuilder()
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
processPurchases(result.purchasesList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun purchase(activity: Activity) {
|
||||||
|
val details = productDetails ?: run {
|
||||||
|
Log.e(TAG, "Product details not loaded yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||||
|
.setProductDetailsParamsList(
|
||||||
|
listOf(
|
||||||
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||||
|
.setProductDetails(details)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
|
||||||
|
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
|
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
||||||
|
when (result.responseCode) {
|
||||||
|
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
|
||||||
|
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
|
||||||
|
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPurchases(purchases: List<Purchase>) {
|
||||||
|
val hasPremium = purchases.any {
|
||||||
|
it.products.contains(PREMIUM_PRODUCT_ID) &&
|
||||||
|
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||||
|
}
|
||||||
|
|
||||||
|
// val purchase = purchases.find {
|
||||||
|
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (purchase != null) {
|
||||||
|
// val consumeParams = ConsumeParams.newBuilder()
|
||||||
|
// .setPurchaseToken(purchase.purchaseToken)
|
||||||
|
// .build()
|
||||||
|
// scope.launch {
|
||||||
|
// billingClient.consumeAsync(consumeParams) { _, _ ->}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
_isPremium.value = hasPremium
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
purchases
|
||||||
|
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
||||||
|
.forEach { acknowledge(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acknowledge(purchase: Purchase) {
|
||||||
|
val params = AcknowledgePurchaseParams.newBuilder()
|
||||||
|
.setPurchaseToken(purchase.purchaseToken)
|
||||||
|
.build()
|
||||||
|
val result = billingClient.acknowledgePurchase(params)
|
||||||
|
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
|
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryPurchases() {
|
||||||
|
scope.launch {
|
||||||
|
queryExistingPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun restorePurchases() {
|
||||||
|
queryPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
@@ -43,7 +43,7 @@ class AACPManager {
|
|||||||
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 INFORMATION: Byte = 0x1D
|
||||||
const val RENAME: Byte = 0x1E
|
const val RENAME: Byte = 0x1A
|
||||||
const val HEADTRACKING: Byte = 0x17
|
const val HEADTRACKING: Byte = 0x17
|
||||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
@@ -55,13 +55,13 @@ class AACPManager {
|
|||||||
const val TIPI_3: Byte = 0x0C // Don't know this one
|
const val TIPI_3: Byte = 0x0C // Don't know this one
|
||||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||||
|
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
data class ControlCommandStatus(
|
data class ControlCommandStatus(
|
||||||
val identifier: ControlCommandIdentifiers,
|
val identifier: ControlCommandIdentifiers, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -84,42 +84,31 @@ class AACPManager {
|
|||||||
|
|
||||||
// @Suppress("unused")
|
// @Suppress("unused")
|
||||||
enum class ControlCommandIdentifiers(val value: Byte) {
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
MIC_MODE(0x01),
|
MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
|
||||||
BUTTON_SEND_MODE(0x05),
|
0x15
|
||||||
VOICE_TRIGGER(0x12),
|
),
|
||||||
SINGLE_CLICK_MODE(0x14),
|
CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
|
||||||
DOUBLE_CLICK_MODE(0x15),
|
0x1A
|
||||||
CLICK_HOLD_MODE(0x16),
|
),
|
||||||
DOUBLE_CLICK_INTERVAL(0x17),
|
ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
|
||||||
CLICK_HOLD_INTERVAL(0x18),
|
0x1E
|
||||||
LISTENING_MODE_CONFIGS(0x1A),
|
),
|
||||||
ONE_BUD_ANC_MODE(0x1B),
|
CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
|
||||||
CROWN_ROTATION_DIRECTION(0x1C),
|
0x25
|
||||||
LISTENING_MODE(0x0D),
|
),
|
||||||
AUTO_ANSWER_MODE(0x1E),
|
ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
|
||||||
CHIME_VOLUME(0x1F),
|
0x28
|
||||||
VOLUME_SWIPE_INTERVAL(0x23),
|
),
|
||||||
CALL_MANAGEMENT_CONFIG(0x24),
|
SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
|
||||||
VOLUME_SWIPE_MODE(0x25),
|
0x30
|
||||||
ADAPTIVE_VOLUME_CONFIG(0x26),
|
),
|
||||||
SOFTWARE_MUTE_CONFIG(0x27),
|
IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
|
||||||
CONVERSATION_DETECT_CONFIG(0x28),
|
0x34
|
||||||
SSL(0x29),
|
),
|
||||||
HEARING_AID(0x2C),
|
STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
|
||||||
AUTO_ANC_STRENGTH(0x2E),
|
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
|
||||||
HPS_GAIN_SWIPE(0x2F),
|
0x37
|
||||||
HRM_STATE(0x30),
|
),
|
||||||
IN_CASE_TONE_CONFIG(0x31),
|
|
||||||
SIRI_MULTITONE_CONFIG(0x32),
|
|
||||||
HEARING_ASSIST_CONFIG(0x33),
|
|
||||||
ALLOW_OFF_OPTION(0x34),
|
|
||||||
STEM_CONFIG(0x39),
|
|
||||||
SLEEP_DETECTION_CONFIG(0x35),
|
|
||||||
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),
|
|
||||||
AUTOMATIC_CONNECTION_CONFIG(0x20),
|
|
||||||
OWNS_CONNECTION(0x06),
|
|
||||||
PPE_TOGGLE_CONFIG(0x37),
|
|
||||||
PPE_CAP_LEVEL_CONFIG(0x38);
|
PPE_CAP_LEVEL_CONFIG(0x38);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -129,59 +118,44 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ProximityKeyType(val value: Byte) {
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
IRK(0x01),
|
IRK(0x01), ENC_KEY(0x04);
|
||||||
ENC_KEY(0x04);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): ProximityKeyType =
|
fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
|
||||||
ProximityKeyType.entries.find { it.value == byte }
|
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressType(val value: Byte) {
|
enum class StemPressType(val value: Byte) {
|
||||||
SINGLE_PRESS(0x05),
|
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
|
||||||
DOUBLE_PRESS(0x06),
|
|
||||||
TRIPLE_PRESS(0x07),
|
|
||||||
LONG_PRESS(0x08);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressType? =
|
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressBudType(val value: Byte) {
|
enum class StemPressBudType(val value: Byte) {
|
||||||
LEFT(0x01),
|
LEFT(0x01), RIGHT(0x02);
|
||||||
RIGHT(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressBudType? =
|
fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AudioSourceType(val value: Byte) {
|
enum class AudioSourceType(val value: Byte) {
|
||||||
NONE(0x00),
|
NONE(0x00), CALL(0x01), MEDIA(0x02);
|
||||||
CALL(0x01),
|
|
||||||
MEDIA(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): AudioSourceType? =
|
fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AudioSource(
|
data class AudioSource(
|
||||||
val mac: String,
|
val mac: String, val type: AudioSourceType
|
||||||
val type: AudioSourceType
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ConnectedDevice(
|
data class ConnectedDevice(
|
||||||
val mac: String,
|
val mac: String, val info1: Byte, val info2: Byte, var type: String?
|
||||||
val info1: Byte,
|
|
||||||
val info2: Byte,
|
|
||||||
var type: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AirPodsInformation(
|
data class AirPodsInformation(
|
||||||
@@ -216,7 +190,7 @@ class AACPManager {
|
|||||||
var audioSource: AudioSource? = null
|
var audioSource: AudioSource? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqData = FloatArray(8) { 0.0f }
|
var eqData = FloatArray(8)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqOnPhone: Boolean = false
|
var eqOnPhone: Boolean = false
|
||||||
@@ -230,8 +204,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setControlCommandStatusValue(
|
private fun setControlCommandStatusValue(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||||
value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
val existingStatus = getControlCommandStatus(identifier)
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
if (existingStatus == value) {
|
if (existingStatus == value) {
|
||||||
@@ -265,6 +238,7 @@ class AACPManager {
|
|||||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||||
fun onShowNearbyUI(sender: String)
|
fun onShowNearbyUI(sender: String)
|
||||||
|
fun onEQPacketReceived(eqData: FloatArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
@@ -287,15 +261,13 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun registerControlCommandListener(
|
fun registerControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterControlCommandListener(
|
fun unregisterControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners[identifier]?.remove(callback)
|
controlCommandListeners[identifier]?.remove(callback)
|
||||||
}
|
}
|
||||||
@@ -330,8 +302,7 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, value)
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
|
||||||
value
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
@@ -340,16 +311,14 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
|
||||||
byteArrayOf(value)
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(
|
val controlPacket = createControlCommandPacket(
|
||||||
identifier,
|
identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
|
||||||
)
|
)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
@@ -369,8 +338,7 @@ class AACPManager {
|
|||||||
|
|
||||||
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
if (data.size < 4) {
|
if (data.size < 4) {
|
||||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
@@ -395,14 +363,18 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
val key = ByteArray(keyLength)
|
val key = ByteArray(keyLength)
|
||||||
System.arraycopy(data, offset, key, 0, keyLength)
|
System.arraycopy(data, offset, key, 0, keyLength)
|
||||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
try {
|
||||||
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(
|
||||||
|
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
offset += keyLength
|
offset += keyLength
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
||||||
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
key.joinToString(" ") { "%02X".format(it) }
|
||||||
key.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@@ -422,26 +394,21 @@ class AACPManager {
|
|||||||
fun receivePacket(packet: ByteArray) {
|
fun receivePacket(packet: ByteArray) {
|
||||||
if (!packet.toHexString().startsWith("04000400")) {
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet does not start with expected header: ${
|
||||||
"Received packet does not start with expected header: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packet.size < 6) {
|
if (packet.size < 6) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val opcode = packet[4]
|
when (val opcode = packet[4]) {
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcodes.BATTERY_INFO -> {
|
Opcodes.BATTERY_INFO -> {
|
||||||
callback?.onBatteryInfoReceived(packet)
|
callback?.onBatteryInfoReceived(packet)
|
||||||
}
|
}
|
||||||
@@ -456,10 +423,9 @@ class AACPManager {
|
|||||||
TAG,
|
TAG,
|
||||||
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
}"
|
}")
|
||||||
)
|
|
||||||
Log.d(
|
val controlCommandListText = try {
|
||||||
TAG, "Control command list is now: ${
|
|
||||||
controlCommandStatusList.joinToString(", ") { it ->
|
controlCommandStatusList.joinToString(", ") { it ->
|
||||||
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
||||||
it.value.joinToString(
|
it.value.joinToString(
|
||||||
@@ -467,12 +433,19 @@ class AACPManager {
|
|||||||
) { "%02X".format(it) }
|
) { "%02X".format(it) }
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
}")
|
} catch (e: Exception) {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG, "Control command list is now: $controlCommandListText"
|
||||||
|
)
|
||||||
|
|
||||||
val controlCommandIdentifier =
|
val controlCommandIdentifier =
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
if (controlCommandIdentifier != null) {
|
if (controlCommandIdentifier != null) {
|
||||||
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
|
||||||
listener.onControlCommandReceived(controlCommand)
|
listener.onControlCommandReceived(controlCommand)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -500,13 +473,11 @@ class AACPManager {
|
|||||||
Opcodes.HEADTRACKING -> {
|
Opcodes.HEADTRACKING -> {
|
||||||
if (packet.size < 70) {
|
if (packet.size < 70) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received HEADTRACKING packet too short: ${
|
||||||
"Received HEADTRACKING packet too short: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
callback?.onHeadTrackingReceived(packet)
|
callback?.onHeadTrackingReceived(packet)
|
||||||
@@ -538,7 +509,8 @@ class AACPManager {
|
|||||||
|
|
||||||
Opcodes.SMART_ROUTING_RESP -> {
|
Opcodes.SMART_ROUTING_RESP -> {
|
||||||
val packetString = packet.decodeToString()
|
val packetString = packet.decodeToString()
|
||||||
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
val sender =
|
||||||
|
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
||||||
|
|
||||||
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
||||||
// val nameStartIndex = packetString.indexOf("btName") + 8
|
// val nameStartIndex = packetString.indexOf("btName") + 8
|
||||||
@@ -558,9 +530,15 @@ class AACPManager {
|
|||||||
} else if ("Android" in packetString) {
|
} else if ("Android" in packetString) {
|
||||||
connectedDevices.find { it.mac == sender }?.type = "Android"
|
connectedDevices.find { it.mac == sender }?.type = "Android"
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
|
||||||
|
)
|
||||||
if (packetString.contains("SetOwnershipToFalse")) {
|
if (packetString.contains("SetOwnershipToFalse")) {
|
||||||
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
|
callback?.onOwnershipToFalseRequest(
|
||||||
|
sender,
|
||||||
|
packetString.contains("ReverseBannerTapped")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (packetString.contains("ShowNearbyUI")) {
|
if (packetString.contains("ShowNearbyUI")) {
|
||||||
callback?.onShowNearbyUI(sender)
|
callback?.onShowNearbyUI(sender)
|
||||||
@@ -585,25 +563,33 @@ class AACPManager {
|
|||||||
|
|
||||||
eqOnMedia = (packet[10] == 0x01.toByte())
|
eqOnMedia = (packet[10] == 0x01.toByte())
|
||||||
eqOnPhone = (packet[11] == 0x01.toByte())
|
eqOnPhone = (packet[11] == 0x01.toByte())
|
||||||
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird.
|
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
|
||||||
// the EQs are little endian floats
|
// the EQs are little endian floats
|
||||||
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
val eq1 =
|
||||||
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
|
||||||
// for now, taking just the first EQ
|
// for now, taking just the first EQ
|
||||||
eqData = FloatArray(8) { i -> eq1.get(i) }
|
eqData = FloatArray(8) { i -> eq1.get(i) }
|
||||||
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
callback?.onEQPacketReceived(eqData)
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.INFORMATION -> {
|
Opcodes.INFORMATION -> {
|
||||||
Log.e(TAG, "Parsing Information Packet")
|
Log.d(TAG, "Parsing Information Packet")
|
||||||
val information = parseInformationPacket(packet)
|
val information = parseInformationPacket(packet)
|
||||||
callback?.onDeviceInformationReceived(information)
|
callback?.onDeviceInformationReceived(information)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
|
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,10 +619,22 @@ class AACPManager {
|
|||||||
|
|
||||||
fun createHandshakePacket(): ByteArray {
|
fun createHandshakePacket(): ByteArray {
|
||||||
return byteArrayOf(
|
return byteArrayOf(
|
||||||
0x00, 0x00, 0x04, 0x00,
|
0x00,
|
||||||
0x01, 0x00, 0x02, 0x00,
|
0x00,
|
||||||
0x00, 0x00, 0x00, 0x00,
|
0x04,
|
||||||
0x00, 0x00, 0x00, 0x00
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,25 +771,40 @@ class AACPManager {
|
|||||||
val packet = ByteArray(5 + size)
|
val packet = ByteArray(5 + size)
|
||||||
packet[0] = Opcodes.RENAME
|
packet[0] = Opcodes.RENAME
|
||||||
packet[1] = 0x00
|
packet[1] = 0x00
|
||||||
packet[2] = size.toByte()
|
packet[2] = 0x01
|
||||||
packet[3] = 0x00
|
packet[3] = size.toByte()
|
||||||
System.arraycopy(nameBytes, 0, packet, 4, size)
|
packet[4] = 0x00
|
||||||
|
System.arraycopy(nameBytes, 0, packet, 5, size)
|
||||||
|
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
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")
|
||||||
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
|
return sendDataPacket(
|
||||||
|
createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress,
|
||||||
|
targetMacAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
|
fun createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress: String,
|
||||||
|
targetMacAddress: String
|
||||||
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(116)
|
val buffer = ByteBuffer.allocate(116)
|
||||||
buffer.put(
|
buffer.put(
|
||||||
@@ -880,17 +893,13 @@ class AACPManager {
|
|||||||
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
||||||
return sendDataPacket(
|
return sendDataPacket(
|
||||||
createMediaInformationPacket(
|
createMediaInformationPacket(
|
||||||
selfMacAddress,
|
selfMacAddress, targetMac, streamingState
|
||||||
targetMac,
|
|
||||||
streamingState
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationPacket(
|
fun createMediaInformationPacket(
|
||||||
selfMacAddress: String,
|
selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
|
||||||
targetMacAddress: String,
|
|
||||||
streamingState: Boolean = true
|
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(138)
|
val buffer = ByteBuffer.allocate(138)
|
||||||
@@ -905,7 +914,7 @@ class AACPManager {
|
|||||||
)
|
)
|
||||||
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
|
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
|
||||||
buffer.put("PlayingApp".toByteArray())
|
buffer.put("PlayingApp".toByteArray())
|
||||||
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator
|
buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
|
||||||
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
|
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
|
||||||
buffer.put(byteArrayOf(0x52)) // 'R'
|
buffer.put(byteArrayOf(0x52)) // 'R'
|
||||||
buffer.put("HostStreamingState".toByteArray())
|
buffer.put("HostStreamingState".toByteArray())
|
||||||
@@ -923,7 +932,7 @@ class AACPManager {
|
|||||||
buffer.put("AudioCategory".toByteArray())
|
buffer.put("AudioCategory".toByteArray())
|
||||||
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
||||||
|
|
||||||
return opcode+buffer.array()
|
return opcode + buffer.array()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
||||||
@@ -1005,9 +1014,15 @@ 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")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
||||||
@@ -1041,8 +1056,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ControlCommand(
|
data class ControlCommand(
|
||||||
val identifier: Byte,
|
val identifier: Byte, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -1094,10 +1108,8 @@ class AACPManager {
|
|||||||
triplePressCustomized: Boolean = false,
|
triplePressCustomized: Boolean = false,
|
||||||
longPressCustomized: Boolean = false
|
longPressCustomized: Boolean = false
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val value = ((if (singlePressCustomized) 0x01 else 0) or
|
val value =
|
||||||
(if (doublePressCustomized) 0x02 else 0) or
|
((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
|
||||||
(if (triplePressCustomized) 0x04 else 0) or
|
|
||||||
(if (longPressCustomized) 0x08 else 0)).toByte()
|
|
||||||
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
||||||
return sendControlCommand(
|
return sendControlCommand(
|
||||||
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
||||||
@@ -1112,19 +1124,18 @@ class AACPManager {
|
|||||||
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
|
||||||
"Control command: ${controlCommand.identifier.toHexString()} - ${
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
controlCommand.value
|
controlCommand.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket()
|
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
||||||
if (socket?.isConnected == true) {
|
|
||||||
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
socket.outputStream?.flush()
|
socket.outputStream?.flush()
|
||||||
return true
|
return true
|
||||||
@@ -1201,7 +1212,11 @@ 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) {
|
||||||
throw IllegalArgumentException("Data array too short to parse all connected devices")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Data array too short to parse all connected devices, returning what we have"
|
||||||
|
)
|
||||||
|
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) }
|
||||||
@@ -1214,6 +1229,7 @@ class AACPManager {
|
|||||||
|
|
||||||
return devices
|
return devices
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSomePacketIDontKnowWhatItIs() {
|
fun sendSomePacketIDontKnowWhatItIs() {
|
||||||
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
||||||
sendDataPacket(
|
sendDataPacket(
|
||||||
@@ -1,37 +1,38 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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,
|
||||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
@@ -49,7 +50,7 @@ enum class ATTCCCDHandles(val value: Int) {
|
|||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val device: BluetoothDevice) {
|
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ATTManager"
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
@@ -62,18 +63,26 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
private var input: InputStream? = null
|
private var input: InputStream? = null
|
||||||
private var output: OutputStream? = null
|
private var output: OutputStream? = null
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||||
private var notificationJob: kotlinx.coroutines.Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
// queue for non-notification PDUs (responses to requests)
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun connect() {
|
fun connect() {
|
||||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
socket = createBluetoothSocket(device, uuid)
|
try {
|
||||||
socket!!.connect()
|
socket = createBluetoothSocket(adapter, device, uuid)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to create socket")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket!!.connect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ATT socket failed to connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
input = socket!!.inputStream
|
input = socket!!.inputStream
|
||||||
output = socket!!.outputStream
|
output = socket!!.outputStream
|
||||||
Log.d(TAG, "Connected to ATT")
|
Log.d(TAG, "Connected to ATT")
|
||||||
@@ -195,9 +204,10 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||||
val type = 3 // L2CAP
|
val type = 3 // L2CAP
|
||||||
val constructorSpecs = listOf(
|
val constructorSpecs = listOf(
|
||||||
|
arrayOf(adapter, device, type, true, true, 31, uuid),
|
||||||
arrayOf(device, type, true, true, 31, uuid),
|
arrayOf(device, type, true, true, 31, uuid),
|
||||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||||
@@ -219,7 +229,12 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
try {
|
try {
|
||||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||||
attemptedConstructors++
|
attemptedConstructors++
|
||||||
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
|
|
||||||
|
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||||
|
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||||
|
constructor.isAccessible = true
|
||||||
|
return constructor.newInstance(*params) as BluetoothSocket
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||||
lastException = e
|
lastException = e
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple's 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
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.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.BluetoothCryptography
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.collections.iterator
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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.bluetooth
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object BluetoothConnectionManager {
|
||||||
|
private const val TAG = "BluetoothConnectionManager"
|
||||||
|
|
||||||
|
private var currentSocket: BluetoothSocket? = null
|
||||||
|
private var currentDevice: BluetoothDevice? = null
|
||||||
|
|
||||||
|
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||||
|
currentSocket = socket
|
||||||
|
currentDevice = device
|
||||||
|
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSocket(): BluetoothSocket? {
|
||||||
|
return currentSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicator(
|
|
||||||
batteryPercentage: Int,
|
|
||||||
charging: Boolean = false,
|
|
||||||
prefix: String = "",
|
|
||||||
previousCharging: Boolean = false,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val batteryFillColor = if (batteryPercentage > 25)
|
|
||||||
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
|
||||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
|
||||||
|
|
||||||
val initialScale = if (previousCharging) 1f else 0f
|
|
||||||
val scaleAnim = remember { Animatable(initialScale) }
|
|
||||||
val targetScale = if (charging) 1f else 0f
|
|
||||||
|
|
||||||
LaunchedEffect(previousCharging, charging) {
|
|
||||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(backgroundColor), // just for haze to work
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = { batteryPercentage / 100f },
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
color = batteryFillColor,
|
|
||||||
gapSize = 0.dp,
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
strokeWidth = 4.dp,
|
|
||||||
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEE6",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = batteryFillColor,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
modifier = Modifier.scale(scaleAnim.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$prefix $batteryPercentage%",
|
|
||||||
color = batteryTextColor,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicatorPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.imageResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
|
||||||
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") val batteryReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
|
|
||||||
batteryStatus.value =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
|
||||||
} else {
|
|
||||||
intent.getParcelableArrayListExtra("data")
|
|
||||||
}?.toList() ?: listOf()
|
|
||||||
}
|
|
||||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
}
|
|
||||||
catch (_: IllegalArgumentException) {
|
|
||||||
Log.wtf("BatteryReceiver", "Receiver already unregistered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(context) {
|
|
||||||
val batteryIntentFilter = IntentFilter()
|
|
||||||
.apply {
|
|
||||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(
|
|
||||||
batteryReceiver,
|
|
||||||
batteryIntentFilter,
|
|
||||||
Context.RECEIVER_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
batteryStatus.value = service.getBattery()
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
batteryStatus.value = listOf(
|
|
||||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
|
|
||||||
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
|
|
||||||
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
|
|
||||||
)
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
}
|
|
||||||
|
|
||||||
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val leftLevel = left?.level ?: 0
|
|
||||||
val rightLevel = right?.level ?: 0
|
|
||||||
val caseLevel = case?.level ?: 0
|
|
||||||
val leftCharging = left?.status == BatteryStatus.CHARGING
|
|
||||||
val rightCharging = right?.status == BatteryStatus.CHARGING
|
|
||||||
val caseCharging = case?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
|
|
||||||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
|
|
||||||
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val singleDisplayed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val budsRes = airpodsInstance.model.budsRes
|
|
||||||
val caseRes = airpodsInstance.model.caseRes
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.5f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image (
|
|
||||||
bitmap = ImageBitmap.imageResource(budsRes),
|
|
||||||
contentDescription = stringResource(R.string.buds),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
leftCharging == rightCharging &&
|
|
||||||
(leftLevel - rightLevel) in -3..3
|
|
||||||
)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel.coerceAtMost(rightLevel),
|
|
||||||
leftCharging,
|
|
||||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
|
||||||
)
|
|
||||||
singleDisplayed.value = true
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
singleDisplayed.value = false
|
|
||||||
Row (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel,
|
|
||||||
leftCharging,
|
|
||||||
"\uDBC6\uDCE5",
|
|
||||||
previousCharging = prevLeftCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (leftLevel > 0 && rightLevel > 0)
|
|
||||||
{
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
rightLevel,
|
|
||||||
rightCharging,
|
|
||||||
"\uDBC6\uDCE8",
|
|
||||||
previousCharging = prevRightCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = ImageBitmap.imageResource(caseRes),
|
|
||||||
contentDescription = stringResource(R.string.case_alt),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
caseLevel,
|
|
||||||
caseCharging,
|
|
||||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
|
||||||
previousCharging = prevCaseCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryViewPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryView(AirPodsService(), preview = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ConnectionSettings() {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.ear_detection),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
|
||||||
sharedPreferenceKey = "automatic_ear_detection",
|
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.automatically_connect),
|
|
||||||
description = stringResource(R.string.automatically_connect_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
|
||||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ConnectionSettingsPreview() {
|
|
||||||
ConnectionSettings()
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HearingHealthSettings(navController: NavController) {
|
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hearing_health),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_protection",
|
|
||||||
name = stringResource(R.string.hearing_protection),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,682 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
var checked by checkedState
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val service = ServiceManager.getService() ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == controlCommandIdentifier
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == controlCommandIdentifier.value) {
|
|
||||||
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
|
|
||||||
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
attHandle: ATTHandles,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt()
|
|
||||||
var checked by remember { mutableStateOf(checkedValue !=0) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
|
|
||||||
attManager.enableNotifications(attHandle)
|
|
||||||
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(checked) {
|
|
||||||
if (attManager.socket?.isConnected != true) return@LaunchedEffect
|
|
||||||
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
if (value.isNotEmpty()) {
|
|
||||||
checked = value[0].toInt() != 0
|
|
||||||
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
|
|
||||||
} else {
|
|
||||||
Log.w("StyledToggle", "Empty value in notification for $label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
attManager.registerListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun StyledTogglePreview() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sharedPrefs = context.getSharedPreferences("preview", 0)
|
|
||||||
StyledToggle(
|
|
||||||
label = "Example Toggle",
|
|
||||||
description = "This is an example description for the styled toggle.",
|
|
||||||
sharedPreferences = sharedPrefs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
|
||||||
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
|
|
||||||
enum class StemAction {
|
|
||||||
PLAY_PAUSE,
|
|
||||||
PREVIOUS_TRACK,
|
|
||||||
NEXT_TRACK,
|
|
||||||
DIGITAL_ASSISTANT,
|
|
||||||
CYCLE_NOISE_CONTROL_MODES;
|
|
||||||
companion object {
|
|
||||||
fun fromString(action: String): StemAction? {
|
|
||||||
return entries.find { it.name == action }
|
|
||||||
}
|
|
||||||
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
|
||||||
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
|
||||||
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
|
||||||
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
|
||||||
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.data
|
||||||
|
|
||||||
|
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
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_1_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods2: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2032", "A2031"),
|
||||||
|
name = "AirPods 2",
|
||||||
|
// budCaseRes = R.drawable.airpods_2
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_2_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_2_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_2_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_2_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = emptySet()
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods3: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2565", "A2564"),
|
||||||
|
name = "AirPods 3",
|
||||||
|
// budCaseRes = R.drawable.airpods_3
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_3_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.HEAD_GESTURES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPods4: AirPodsBase(
|
||||||
|
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||||
|
name = "AirPods 4",
|
||||||
|
// budCaseRes = R.drawable.airpods_4
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_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
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
|
capabilities = setOf(
|
||||||
|
Capability.LISTENING_MODE,
|
||||||
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.HEAD_GESTURES,
|
||||||
|
Capability.ADAPTIVE_AUDIO,
|
||||||
|
Capability.SLEEP_DETECTION,
|
||||||
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.STEM_CONFIG
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsPro1: AirPodsBase(
|
||||||
|
modelNumber = listOf("A2084", "A2083"),
|
||||||
|
name = "AirPods Pro 1",
|
||||||
|
displayName = "AirPods Pro",
|
||||||
|
// budCaseRes = R.drawable.airpods_pro_1
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_1_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_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
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
|
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
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
|
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
|
||||||
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_3_buds
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_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?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object AirPodsModels {
|
||||||
|
val models: List<AirPodsBase> = listOf(
|
||||||
|
AirPods(),
|
||||||
|
AirPods2(),
|
||||||
|
AirPods3(),
|
||||||
|
AirPods4(),
|
||||||
|
AirPods4ANC(),
|
||||||
|
AirPodsPro1(),
|
||||||
|
AirPodsPro2Lightning(),
|
||||||
|
AirPodsPro2USBC(),
|
||||||
|
AirPodsPro3()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
|
||||||
|
return models.find { modelNumber in it.modelNumber }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
|
||||||
|
class ControlCommandRepository(
|
||||||
|
private val aacpManager: AACPManager
|
||||||
|
) {
|
||||||
|
fun getValue(
|
||||||
|
identifier: ControlCommandIdentifiers
|
||||||
|
): ByteArray? {
|
||||||
|
return aacpManager.controlCommandStatusList
|
||||||
|
.find { it.identifier == identifier }
|
||||||
|
?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setValue(
|
||||||
|
id: ControlCommandIdentifiers,
|
||||||
|
value: ByteArray
|
||||||
|
) {
|
||||||
|
aacpManager.sendControlCommand(id.value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun observe(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
onChange: (ByteArray) -> Unit
|
||||||
|
): AACPManager.ControlCommandListener {
|
||||||
|
|
||||||
|
val listener = object : AACPManager.ControlCommandListener {
|
||||||
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
|
onChange(controlCommand.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aacpManager.registerControlCommandListener(identifier, listener)
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
listener: AACPManager.ControlCommandListener
|
||||||
|
) {
|
||||||
|
aacpManager.unregisterControlCommandListener(identifier, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
|
||||||
|
return aacpManager.controlCommandStatusList.associate {
|
||||||
|
it.identifier to it.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
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.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
@@ -25,6 +25,8 @@ 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 kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
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.data
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -40,6 +40,7 @@ object BatteryStatus {
|
|||||||
const val CHARGING = 1
|
const val CHARGING = 1
|
||||||
const val NOT_CHARGING = 2
|
const val NOT_CHARGING = 2
|
||||||
const val DISCONNECTED = 4
|
const val DISCONNECTED = 4
|
||||||
|
const val OPTIMIZED_CHARGING = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -58,6 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
|
|||||||
BatteryStatus.CHARGING -> "CHARGING"
|
BatteryStatus.CHARGING -> "CHARGING"
|
||||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||||
|
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ enum class NoiseControlMode {
|
|||||||
class AirPodsNotifications {
|
class AirPodsNotifications {
|
||||||
companion object {
|
companion object {
|
||||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||||
|
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||||
@@ -78,6 +81,8 @@ class AirPodsNotifications {
|
|||||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||||
|
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
||||||
|
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||||
}
|
}
|
||||||
|
|
||||||
class EarDetection {
|
class EarDetection {
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
|
||||||
|
enum class StemAction {
|
||||||
|
PLAY_PAUSE,
|
||||||
|
PREVIOUS_TRACK,
|
||||||
|
NEXT_TRACK,
|
||||||
|
DIGITAL_ASSISTANT,
|
||||||
|
CYCLE_NOISE_CONTROL_MODES;
|
||||||
|
companion object {
|
||||||
|
fun fromString(action: String): StemAction? {
|
||||||
|
return entries.find { it.name == action }
|
||||||
|
}
|
||||||
|
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
||||||
|
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||||
|
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||||
|
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||||
|
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
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.data
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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 kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -139,7 +140,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
|||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) {
|
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
@@ -171,7 +172,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
|
|||||||
}
|
}
|
||||||
|
|
||||||
val data = buffer.array()
|
val data = buffer.array()
|
||||||
attManager.write(ATTHandles.TRANSPARENCY, value = data)
|
writer(ATTHandles.TRANSPARENCY, data)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
interface XposedRemotePref {
|
||||||
|
fun isAvailable(): Boolean
|
||||||
|
|
||||||
|
fun getBoolean(key: String, def: Boolean): Boolean
|
||||||
|
fun putBoolean(key: String, value: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
object XposedRemotePrefProvider {
|
||||||
|
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||||
|
}
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
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
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutCard(navController: NavController) {
|
fun AboutCard(
|
||||||
|
navController: NavController,
|
||||||
|
modelName: String,
|
||||||
|
actualModel: String,
|
||||||
|
serialNumbers: List<String>,
|
||||||
|
version: String?
|
||||||
|
) {
|
||||||
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
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -75,7 +75,8 @@ fun AboutCard(navController: NavController) {
|
|||||||
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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.model.displayName,
|
text = modelName,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -137,7 +138,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.actualModelNumber,
|
text = actualModel,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -152,11 +153,11 @@ fun AboutCard(navController: NavController) {
|
|||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
)
|
)
|
||||||
val serialNumbers = listOf(
|
val serialNumbers = listOf(
|
||||||
airpodsInstance.serialNumber?: "",
|
serialNumbers[0],
|
||||||
" ${airpodsInstance.leftSerialNumber}",
|
" ${serialNumbers[1]}",
|
||||||
" ${airpodsInstance.rightSerialNumber}"
|
" ${serialNumbers[2]}"
|
||||||
)
|
)
|
||||||
val serialNumber = remember { mutableStateOf(0) }
|
val serialNumber = remember { mutableIntStateOf(0) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -172,7 +173,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = serialNumbers[serialNumber.value],
|
text = serialNumbers[serialNumber.intValue],
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -183,7 +184,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,9 +198,9 @@ fun AboutCard(navController: NavController) {
|
|||||||
to = "version_info",
|
to = "version_info",
|
||||||
navController = navController,
|
navController = navController,
|
||||||
name = stringResource(R.string.version),
|
name = stringResource(R.string.version),
|
||||||
currentState = airpodsInstance.version3,
|
currentState = version,
|
||||||
independent = false,
|
independent = false,
|
||||||
height = rowHeight.value + 32.dp
|
height = rowHeight.value + 32.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
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.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
|
||||||
@@ -42,25 +44,32 @@ 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.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,
|
||||||
|
adaptiveVolumeCapability: Boolean,
|
||||||
|
conversationalAwarenessCapability: Boolean,
|
||||||
|
loudSoundReductionCapability: Boolean,
|
||||||
|
adaptiveAudioCapability: Boolean,
|
||||||
|
|
||||||
|
adaptiveVolumeChecked: Boolean,
|
||||||
|
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
conversationalAwarenessChecked: Boolean,
|
||||||
|
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
loudSoundReductionChecked: Boolean,
|
||||||
|
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
vendorIdHook: Boolean,
|
||||||
|
isPremium: Boolean
|
||||||
|
) {
|
||||||
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
|
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -73,7 +82,8 @@ fun AudioSettings(navController: NavController) {
|
|||||||
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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,12 +98,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
if (adaptiveVolumeCapability) {
|
||||||
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),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = adaptiveVolumeChecked,
|
||||||
|
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -104,12 +116,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
if (conversationalAwarenessCapability) {
|
||||||
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),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = conversationalAwarenessChecked,
|
||||||
|
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -119,12 +133,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
if (loudSoundReductionCapability && vendorIdHook){
|
||||||
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,
|
independent = false,
|
||||||
independent = false
|
checked = loudSoundReductionChecked,
|
||||||
|
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -134,7 +150,7 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
if (adaptiveAudioCapability) {
|
||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "adaptive_strength",
|
to = "adaptive_strength",
|
||||||
name = stringResource(R.string.adaptive_audio),
|
name = stringResource(R.string.adaptive_audio),
|
||||||
@@ -148,5 +164,19 @@ fun AudioSettings(navController: NavController) {
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(rememberNavController())
|
AudioSettings(
|
||||||
|
navController = rememberNavController(),
|
||||||
|
adaptiveVolumeCapability = true,
|
||||||
|
conversationalAwarenessCapability = true,
|
||||||
|
loudSoundReductionCapability = true,
|
||||||
|
adaptiveAudioCapability = true,
|
||||||
|
adaptiveVolumeChecked = true,
|
||||||
|
onAdaptiveVolumeCheckedChange = { },
|
||||||
|
conversationalAwarenessChecked = true,
|
||||||
|
onConversationalAwarenessCheckedChange = { },
|
||||||
|
loudSoundReductionChecked = true,
|
||||||
|
onLoudSoundReductionCheckedChange = { },
|
||||||
|
vendorIdHook = true,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.components
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
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.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicator(
|
||||||
|
batteryPercentage: Int,
|
||||||
|
status: Int,
|
||||||
|
prefix: String = "",
|
||||||
|
previousCharging: Boolean = false,
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val batteryFillColor =
|
||||||
|
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||||
|
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||||
|
|
||||||
|
val initialScale = if (previousCharging) 1f else 0f
|
||||||
|
val scaleAnim = remember { Animatable(initialScale) }
|
||||||
|
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
val targetScale = if (charging) 1f else 0f
|
||||||
|
|
||||||
|
LaunchedEffect(previousCharging, charging) {
|
||||||
|
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
|
||||||
|
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
|
||||||
|
|
||||||
|
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
|
||||||
|
val optimizedLimit = 0.8f
|
||||||
|
val progress = batteryPercentage / 100f
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.size(34.dp)) {
|
||||||
|
val startAngle = -90f
|
||||||
|
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
|
||||||
|
val inset = strokeWidthPx / 2
|
||||||
|
Rect(
|
||||||
|
left = inset,
|
||||||
|
top = inset,
|
||||||
|
right = size.width - inset,
|
||||||
|
bottom = size.height - inset
|
||||||
|
)
|
||||||
|
val radius = size.minDimension / 2
|
||||||
|
|
||||||
|
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * optimizedLimit,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
val sweep = 360f * min(progress, optimizedLimit)
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = sweep,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- PILL MARKER AT 80% ----
|
||||||
|
val angleDeg = startAngle + 360f * optimizedLimit
|
||||||
|
val angleRad = Math.toRadians(angleDeg.toDouble())
|
||||||
|
|
||||||
|
val arcRadius = radius - strokeWidthPx
|
||||||
|
|
||||||
|
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
|
||||||
|
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
|
||||||
|
|
||||||
|
val dirX = center.x - outerX
|
||||||
|
val dirY = center.y - outerY
|
||||||
|
val length = sqrt(dirX * dirX + dirY * dirY)
|
||||||
|
|
||||||
|
val normX = dirX / length
|
||||||
|
val normY = dirY / length
|
||||||
|
|
||||||
|
val startX = outerX - normX * strokeWidthPx / 2
|
||||||
|
val startY = outerY - normY * strokeWidthPx / 2
|
||||||
|
|
||||||
|
val endX = center.x - normX * gapFromCenterPx
|
||||||
|
val endY = center.y - normY * gapFromCenterPx
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
|
||||||
|
start = Offset(startX, startY),
|
||||||
|
end = Offset(endX, endY),
|
||||||
|
strokeWidth = strokeWidthPx,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = 0f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * progress,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "\uDBC0\uDEE6", style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = batteryFillColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
), modifier = Modifier.scale(scaleAnim.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$prefix $batteryPercentage%",
|
||||||
|
color = batteryTextColor,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicatorPreview() {
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(bg)
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
batteryPercentage = 50,
|
||||||
|
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||||
|
prefix = "\uDBC6\uDCE5",
|
||||||
|
previousCharging = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.res.imageResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.Battery
|
||||||
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryView(
|
||||||
|
batteryList: List<Battery>,
|
||||||
|
budsRes: Int,
|
||||||
|
caseRes: Int
|
||||||
|
) {
|
||||||
|
val left = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
val case = batteryList.find { it.component == BatteryComponent.CASE }
|
||||||
|
|
||||||
|
val leftLevel = left?.level ?: 0
|
||||||
|
val rightLevel = right?.level ?: 0
|
||||||
|
val caseLevel = case?.level ?: 0
|
||||||
|
|
||||||
|
val caseCharging = case?.status == BatteryStatus.CHARGING ||
|
||||||
|
case?.status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
|
||||||
|
val singleDisplayed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.widthIn(max = 500.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(budsRes),
|
||||||
|
contentDescription = stringResource(R.string.buds),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
left?.status == right?.status &&
|
||||||
|
(leftLevel - rightLevel) in -3..3
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel.coerceAtMost(rightLevel),
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING
|
||||||
|
)
|
||||||
|
singleDisplayed.value = true
|
||||||
|
} else {
|
||||||
|
singleDisplayed.value = false
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel,
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE5"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftLevel > 0 && rightLevel > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
rightLevel,
|
||||||
|
right?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE8"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(caseRes),
|
||||||
|
contentDescription = stringResource(R.string.case_alt),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
caseLevel,
|
||||||
|
case?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryViewPreview() {
|
||||||
|
val fakeBattery = listOf(
|
||||||
|
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||||
|
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
|
||||||
|
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
|
||||||
|
)
|
||||||
|
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(bg)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = fakeBattery,
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -36,39 +36,43 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
||||||
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun CallControlSettings(hazeState: HazeState) {
|
fun CallControlSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
flipped: Boolean,
|
||||||
|
onCallControlValueChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -82,7 +86,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,24 +98,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
val scope = rememberCoroutineScope()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
val haptics = LocalHapticFeedback.current
|
||||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
|
||||||
|
|
||||||
val pressOnceText = stringResource(R.string.press_once)
|
val pressOnceText = stringResource(R.string.press_once)
|
||||||
val pressTwiceText = stringResource(R.string.press_twice)
|
val pressTwiceText = stringResource(R.string.press_twice)
|
||||||
|
|
||||||
var flipped by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
callControlEnabledValue.contentEquals(
|
|
||||||
byteArrayOf(
|
|
||||||
0x00,
|
|
||||||
0x02
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||||
|
|
||||||
@@ -120,6 +114,7 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||||
@@ -127,36 +122,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
|
||||||
) {
|
|
||||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
|
||||||
flipped = newFlipped
|
|
||||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
|
||||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
|
||||||
Log.d(
|
|
||||||
"CallControlSettings",
|
|
||||||
"Control command received, flipped: $newFlipped"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(flipped) {
|
LaunchedEffect(flipped) {
|
||||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||||
}
|
}
|
||||||
@@ -231,7 +198,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetSingle ?: current
|
val touch = touchOffsetSingle ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxSingle) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = idx
|
parentHoveredIndexSingle = idx
|
||||||
|
previousIdxSingle = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveSingle = false
|
parentDragActiveSingle = false
|
||||||
@@ -244,13 +215,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
lastDismissTimeSingle = System.currentTimeMillis()
|
lastDismissTimeSingle = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
onCallControlValueChanged(option != pressOnceText)
|
||||||
0x00,
|
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = null
|
parentHoveredIndexSingle = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -313,11 +284,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
doublePressAction =
|
doublePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option != pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -366,7 +334,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetDouble ?: current
|
val touch = touchOffsetDouble ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxDouble) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = idx
|
parentHoveredIndexDouble = idx
|
||||||
|
previousIdxDouble = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveDouble = false
|
parentDragActiveDouble = false
|
||||||
@@ -379,13 +351,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
lastDismissTimeDouble = System.currentTimeMillis()
|
lastDismissTimeDouble = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = null
|
parentHoveredIndexDouble = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -448,11 +420,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
singlePressAction =
|
singlePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -461,10 +430,3 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun CallControlSettingsPreview() {
|
|
||||||
CallControlSettings(HazeState())
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -38,13 +39,16 @@ import androidx.compose.runtime.MutableState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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
|
||||||
@@ -53,10 +57,12 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@@ -74,8 +80,18 @@ fun ConfirmationDialog(
|
|||||||
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 accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
if (showDialog.value) {
|
if (showDialog.value) {
|
||||||
Dialog(onDismissRequest = { showDialog.value = false }) {
|
Dialog(
|
||||||
|
onDismissRequest = { showDialog.value = false },
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = false,
|
||||||
|
dismissOnClickOutside = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// .fillMaxWidth(0.75f)
|
// .fillMaxWidth(0.75f)
|
||||||
@@ -90,7 +106,7 @@ fun ConfirmationDialog(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -102,7 +118,7 @@ fun ConfirmationDialog(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -113,7 +129,7 @@ fun ConfirmationDialog(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
color = Color(0x40888888),
|
color = Color(0x40888888),
|
||||||
@@ -148,6 +164,8 @@ fun ConfirmationDialog(
|
|||||||
}
|
}
|
||||||
PointerEventType.Move -> {
|
PointerEventType.Move -> {
|
||||||
if (isWithinBounds) {
|
if (isWithinBounds) {
|
||||||
|
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick) }
|
||||||
leftPressed = isLeft
|
leftPressed = isLeft
|
||||||
rightPressed = !isLeft
|
rightPressed = !isLeft
|
||||||
} else {
|
} else {
|
||||||
@@ -158,8 +176,12 @@ fun ConfirmationDialog(
|
|||||||
PointerEventType.Release -> {
|
PointerEventType.Release -> {
|
||||||
if (isWithinBounds) {
|
if (isWithinBounds) {
|
||||||
if (leftPressed) {
|
if (leftPressed) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.Reject) }
|
||||||
onDismiss()
|
onDismiss()
|
||||||
} else if (rightPressed) {
|
} else if (rightPressed) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.Confirm) }
|
||||||
onConfirm()
|
onConfirm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled: Boolean,
|
||||||
|
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
|
||||||
|
automaticConnectionEnabled: Boolean,
|
||||||
|
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.ear_detection),
|
||||||
|
independent = false,
|
||||||
|
checked = automaticEarDetectionEnabled,
|
||||||
|
onCheckedChange = onAutomaticEarDetectionChanged
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.automatically_connect),
|
||||||
|
description = stringResource(R.string.automatically_connect_description),
|
||||||
|
independent = false,
|
||||||
|
checked = automaticConnectionEnabled,
|
||||||
|
onCheckedChange = onAutomaticConnectionChanged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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")
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
@@ -56,7 +56,7 @@ 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 me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
|
|
||||||
private val ContainerColor = Color(0x593C3C3E)
|
private val ContainerColor = Color(0x593C3C3E)
|
||||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.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.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceInfoCard() {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Column (
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_info), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(start = 16.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.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.manufacturer), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.MANUFACTURER, 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_number), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.MODEL, 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.build_id), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.DISPLAY, 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.version), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.components
|
||||||
|
|
||||||
|
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.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingHealthSettings(
|
||||||
|
navController: NavController,
|
||||||
|
hasPPECapability: Boolean,
|
||||||
|
hasHearingAidCapability: Boolean,
|
||||||
|
vendorIdHook: Boolean
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
|
||||||
|
|
||||||
|
if (hasPPECapability && shouldShowHearingAid) {
|
||||||
|
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),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 if (shouldShowHearingAid) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
} else if (hasPPECapability) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
title = stringResource(R.string.hearing_health),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
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
|
||||||
@@ -35,38 +34,41 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
||||||
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 dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneSettings(hazeState: HazeState) {
|
fun MicrophoneSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
micModeValue: Byte,
|
||||||
|
onMicModeValueChanged: (Byte) -> Unit
|
||||||
|
) {
|
||||||
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -77,11 +79,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
}?.value?.get(0) ?: 0x00.toByte()
|
|
||||||
|
|
||||||
var selectedMode by remember {
|
var selectedMode by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
when (micModeValue) {
|
when (micModeValue) {
|
||||||
@@ -98,42 +95,13 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||||
val reopenThresholdMs = 250L
|
val reopenThresholdMs = 250L
|
||||||
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
) {
|
|
||||||
selectedMode = when (controlCommand.value[0]) {
|
|
||||||
0x00.toByte() -> "Automatic"
|
|
||||||
0x01.toByte() -> "Always Right"
|
|
||||||
0x02.toByte() -> "Always Left"
|
|
||||||
else -> "Automatic"
|
|
||||||
}
|
|
||||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActive by remember { mutableStateOf(false) }
|
var parentDragActive by remember { mutableStateOf(false) }
|
||||||
|
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||||
@@ -173,7 +141,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffset ?: current
|
val touch = touchOffset ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdx) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndex = idx
|
parentHoveredIndex = idx
|
||||||
|
previousIdx = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActive = false
|
parentDragActive = false
|
||||||
@@ -194,12 +166,16 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
options[2] -> 0x02
|
options[2] -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
// service.aacpManager.sendControlCommand(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||||
byteArrayOf(byteValue.toByte())
|
// byteArrayOf(byteValue.toByte())
|
||||||
)
|
// )
|
||||||
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndex = null
|
parentHoveredIndex = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -277,10 +253,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
microphoneAlwaysLeftText -> 0x02
|
microphoneAlwaysLeftText -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
|
||||||
byteArrayOf(byteValue.toByte())
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -288,10 +261,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneSettingsPreview() {
|
|
||||||
MicrophoneSettings(HazeState())
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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
|
||||||
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,10 +66,14 @@ fun NavigationButton(
|
|||||||
description: String? = null,
|
description: String? = null,
|
||||||
currentState: String? = null,
|
currentState: String? = null,
|
||||||
height: Dp = 58.dp,
|
height: Dp = 58.dp,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
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)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -79,23 +87,34 @@ fun NavigationButton(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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(height)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (enabled) {
|
||||||
tryAwaitRelease()
|
backgroundColor =
|
||||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
if (onClick != null) onClick() else navController.navigate(to)
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
|
if (onClick != null) onClick() else navController.navigate(to)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() {
|
|||||||
onClick = {},
|
onClick = {},
|
||||||
textColor = Color.White,
|
textColor = Color.White,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,26 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.SpringSpec
|
import androidx.compose.animation.core.SpringSpec
|
||||||
@@ -60,64 +55,49 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.imageResource
|
import androidx.compose.ui.res.imageResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
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 me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
showOffListeningMode: Boolean,
|
||||||
|
noiseControlModeValue: Int,
|
||||||
|
onNoiseControlModeChanged: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
|
||||||
|
|
||||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
offListeningModeListener
|
|
||||||
)
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||||
|
|
||||||
val d1a = remember { mutableFloatStateOf(0f) }
|
val d1a = remember { mutableFloatStateOf(0f) }
|
||||||
val d2a = remember { mutableFloatStateOf(0f) }
|
val d2a = remember { mutableFloatStateOf(0f) }
|
||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
// this function exists solely for the dividers, should get rid of it
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||||
val previousMode = noiseControlMode.value
|
val previousMode = noiseControlMode.value
|
||||||
|
|
||||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
|
||||||
NoiseControlMode.TRANSPARENCY
|
NoiseControlMode.TRANSPARENCY
|
||||||
} else {
|
} else {
|
||||||
mode
|
mode
|
||||||
@@ -125,9 +105,8 @@ fun NoiseControlSettings(
|
|||||||
|
|
||||||
noiseControlMode.value = targetMode
|
noiseControlMode.value = targetMode
|
||||||
|
|
||||||
if (!received && targetMode != previousMode) {
|
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
|
||||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (noiseControlMode.value) {
|
when (noiseControlMode.value) {
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
@@ -153,32 +132,12 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val noiseControlReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
|
||||||
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
|
|
||||||
onModeSelected(noiseControlMode.value, true)
|
|
||||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val noiseControlIntentFilter = IntentFilter().apply {
|
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
|
||||||
addAction(AirPodsNotifications.ANC_DATA)
|
noiseControlMode.value = NoiseControlMode.entries[index]
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
onModeSelected(noiseControlMode.value, received = true)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
@@ -190,17 +149,18 @@ fun NoiseControlSettings(
|
|||||||
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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val buttonCount = if (offListeningMode.value) 4 else 3
|
val buttonCount = if (showOffListeningMode) 4 else 3
|
||||||
val buttonWidth = maxWidth / buttonCount
|
val buttonWidth = maxWidth / buttonCount
|
||||||
|
|
||||||
val isDragging = remember { mutableStateOf(false) }
|
val isDragging = remember { mutableStateOf(false) }
|
||||||
@@ -208,10 +168,10 @@ fun NoiseControlSettings(
|
|||||||
mutableFloatStateOf(
|
mutableFloatStateOf(
|
||||||
with(density) {
|
with(density) {
|
||||||
when(noiseControlMode.value) {
|
when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -224,10 +184,10 @@ fun NoiseControlSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedOffset by animateFloatAsState(
|
val animatedOffset by animateFloatAsState(
|
||||||
@@ -250,7 +210,7 @@ fun NoiseControlSettings(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -323,13 +283,12 @@ fun NoiseControlSettings(
|
|||||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
||||||
val newIndex = position.roundToInt()
|
val newIndex = position.roundToInt()
|
||||||
val newMode = when(newIndex) {
|
val newMode = when(newIndex) {
|
||||||
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||||
else -> noiseControlMode.value // Keep current if index is invalid
|
else -> noiseControlMode.value // Keep current if index is invalid
|
||||||
}
|
}
|
||||||
// Call onModeSelected which now handles service call but not callback
|
|
||||||
onModeSelected(newMode)
|
onModeSelected(newMode)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -347,7 +306,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.zIndex(1f)
|
.zIndex(1f)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -406,7 +365,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.off),
|
text = stringResource(R.string.off),
|
||||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||||
@@ -436,9 +395,3 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun NoiseControlSettingsPreview() {
|
|
||||||
NoiseControlSettings(AirPodsService())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
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 com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.effects.blur
|
||||||
|
import com.kyant.backdrop.effects.lens
|
||||||
|
import com.kyant.backdrop.effects.vibrancy
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PlayBypassSheet(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
backdrop: LayerBackdrop
|
||||||
|
) {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val contentColor = if (dark) Color.White else Color.Black
|
||||||
|
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
var acknowledged by remember { mutableStateOf(false) }
|
||||||
|
val inputState = rememberTextFieldState("")
|
||||||
|
|
||||||
|
val isValid = acknowledged && inputState.text.trim() == "OK"
|
||||||
|
|
||||||
|
val sfPro = FontFamily(Font(R.font.sf_pro))
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
dragHandle = { },
|
||||||
|
shape = RoundedCornerShape(48.dp),
|
||||||
|
scrimColor = Color.Transparent,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
val innerBackdrop = rememberLayerBackdrop()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(48.dp))
|
||||||
|
.drawBackdrop(
|
||||||
|
backdrop = backdrop,
|
||||||
|
exportedBackdrop = innerBackdrop,
|
||||||
|
shape = { RoundedCornerShape(48.dp) },
|
||||||
|
effects = {
|
||||||
|
vibrancy()
|
||||||
|
blur(6f.dp.toPx())
|
||||||
|
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
drawRect(
|
||||||
|
if (dark) Color.DarkGray.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bypass_compatibility_check),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = sfPro,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = contentColor
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.compatibility_play_dialog_confirmation),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = sfPro,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = contentColor
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledSelectList(
|
||||||
|
items = listOf(
|
||||||
|
SelectItem(
|
||||||
|
name = stringResource(R.string.read_compatibility_requirements),
|
||||||
|
selected = acknowledged,
|
||||||
|
onClick = { acknowledged = !acknowledged }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
val backgroundColor = if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (dark) Color.White else Color.Black
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(58.dp)
|
||||||
|
.background(
|
||||||
|
backgroundColor,
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
state = inputState,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(textColor),
|
||||||
|
decorator = { innerTextField ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
if (inputState.text == "") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.type_ok_to_continue, "OK"),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontFamily = sfPro,
|
||||||
|
color = textColor.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
inputState.clearText()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = if (dark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = sfPro,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = contentColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StyledButton(
|
||||||
|
onClick = onConfirm,
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
isInteractive = isValid,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = isValid,
|
||||||
|
surfaceColor = if (dark) Color(0xFF0091FF) else Color(0xFF0088FF)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.proceed),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = sfPro,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -35,38 +31,34 @@ import androidx.compose.runtime.Composable
|
|||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
||||||
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.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
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PressAndHoldSettings(navController: NavController) {
|
fun PressAndHoldSettings(
|
||||||
|
navController: NavController,
|
||||||
|
leftAction: StemAction,
|
||||||
|
rightAction: StemAction
|
||||||
|
) {
|
||||||
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 dividerColor = Color(0x40888888)
|
val dividerColor = Color(0x40888888)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val leftActionText = when (leftAction) {
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
|
|
||||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
}
|
}
|
||||||
|
|
||||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
val rightActionText = when (rightAction) {
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun PressAndHoldSettingsPreview() {
|
|
||||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceAtMost
|
import androidx.compose.ui.util.fastCoerceAtMost
|
||||||
@@ -55,7 +57,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
import com.kyant.backdrop.Backdrop
|
import com.kyant.backdrop.Backdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refraction
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.vibrancy
|
import com.kyant.backdrop.effects.vibrancy
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -75,9 +77,12 @@ fun StyledButton(
|
|||||||
tint: Color = Color.Unspecified,
|
tint: Color = Color.Unspecified,
|
||||||
surfaceColor: Color = Color.Unspecified,
|
surfaceColor: Color = Color.Unspecified,
|
||||||
maxScale: Float = 0.1f,
|
maxScale: Float = 0.1f,
|
||||||
|
enabled: Boolean = true,
|
||||||
content: @Composable RowScope.() -> Unit,
|
content: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val isInteractive = enabled && isInteractive
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
@@ -122,8 +127,8 @@ half4 main(float2 coord) {
|
|||||||
} else {
|
} else {
|
||||||
drawRect(Color.White.copy(0.1f))
|
drawRect(Color.White.copy(0.1f))
|
||||||
}
|
}
|
||||||
if (surfaceColor.isSpecified) {
|
if (surfaceColor.isSpecified && enabled) {
|
||||||
val color = if (!isInteractive && isPressed) {
|
val color = if (isPressed) {
|
||||||
Color(
|
Color(
|
||||||
red = surfaceColor.red * 0.5f,
|
red = surfaceColor.red * 0.5f,
|
||||||
green = surfaceColor.green * 0.5f,
|
green = surfaceColor.green * 0.5f,
|
||||||
@@ -134,6 +139,11 @@ half4 main(float2 coord) {
|
|||||||
surfaceColor
|
surfaceColor
|
||||||
}
|
}
|
||||||
drawRect(color)
|
drawRect(color)
|
||||||
|
} else {
|
||||||
|
if (isPressed) {
|
||||||
|
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||||
|
drawRect(Color.White.copy(alpha = 0.2f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDrawFront = null,
|
onDrawFront = null,
|
||||||
@@ -146,7 +156,12 @@ half4 main(float2 coord) {
|
|||||||
effects = {
|
effects = {
|
||||||
vibrancy()
|
vibrancy()
|
||||||
blur(2f.dp.toPx())
|
blur(2f.dp.toPx())
|
||||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
lens(
|
||||||
|
refractionHeight = 12f.dp.toPx(),
|
||||||
|
refractionAmount = 24f.dp.toPx(),
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
layerBlock = {
|
layerBlock = {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
@@ -158,19 +173,21 @@ half4 main(float2 coord) {
|
|||||||
val maxOffset = size.minDimension
|
val maxOffset = size.minDimension
|
||||||
val initialDerivative = 0.05f
|
val initialDerivative = 0.05f
|
||||||
val offset = offsetAnimation.value
|
val offset = offsetAnimation.value
|
||||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
translationX =
|
||||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||||
|
translationY =
|
||||||
|
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||||
|
|
||||||
val maxDragScale = 0.1f
|
val maxDragScale = 0.1f
|
||||||
val offsetAngle = atan2(offset.y, offset.x)
|
val offsetAngle = atan2(offset.y, offset.x)
|
||||||
scaleX =
|
scaleX =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||||
(width / height).fastCoerceAtMost(1f)
|
(width / height).fastCoerceAtMost(1f)
|
||||||
scaleY =
|
scaleY =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||||
(height / width).fastCoerceAtMost(1f)
|
(height / width).fastCoerceAtMost(1f)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
if (tint.isSpecified) {
|
if (tint.isSpecified) {
|
||||||
@@ -204,7 +221,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -231,31 +251,53 @@ half4 main(float2 coord) {
|
|||||||
interactionSource = null,
|
interactionSource = null,
|
||||||
indication = null,
|
indication = null,
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
onClick = onClick
|
onClick = {
|
||||||
|
if (enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (isInteractive) {
|
if (isInteractive) {
|
||||||
Modifier.pointerInput(animationScope) {
|
Modifier.pointerInput(scope) {
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch {
|
||||||
|
offsetAnimation.animateTo(
|
||||||
|
Offset.Zero,
|
||||||
|
offsetAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
1f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { onDragStop() },
|
onDragEnd = {
|
||||||
|
onDragStop()
|
||||||
|
},
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +311,10 @@ half4 main(float2 coord) {
|
|||||||
isPressed = false
|
isPressed = false
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
onClick()
|
if (enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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
|
||||||
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
|
|||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@@ -110,6 +114,9 @@ fun StyledDropdown(
|
|||||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
val itemHeight = 48.dp
|
val itemHeight = 48.dp
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
@@ -132,7 +139,12 @@ fun StyledDropdown(
|
|||||||
},
|
},
|
||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
val y = change.position.y
|
val y = change.position.y
|
||||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||||
|
if (newHoveredIndex != hoveredIndex) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
|
hoveredIndex = newHoveredIndex
|
||||||
lastDragPosition = change.position
|
lastDragPosition = change.position
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
@@ -144,6 +156,8 @@ fun StyledDropdown(
|
|||||||
if (withinBounds) {
|
if (withinBounds) {
|
||||||
hoveredIndex?.let { idx ->
|
hoveredIndex?.let { idx ->
|
||||||
if (idx in options.indices) {
|
if (idx in options.indices) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.GestureEnd) }
|
||||||
onOptionSelected(options[idx])
|
onOptionSelected(options[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +188,7 @@ fun StyledDropdown(
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
onOptionSelected(text)
|
onOptionSelected(text)
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -50,7 +50,9 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
|
|||||||
import androidx.compose.ui.graphics.layer.drawLayer
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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
|
||||||
@@ -63,8 +65,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
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 com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -78,14 +79,15 @@ import kotlin.math.tanh
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledIconButton(
|
fun StyledIconButton(
|
||||||
onClick: () -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
icon: String,
|
icon: String,
|
||||||
darkMode: Boolean,
|
|
||||||
tint: Color = Color.Unspecified,
|
tint: Color = Color.Unspecified,
|
||||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||||
modifier: Modifier = Modifier,
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
@@ -117,7 +119,10 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(56.dp),
|
shape = RoundedCornerShape(56.dp),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
@@ -148,12 +153,12 @@ half4 main(float2 coord) {
|
|||||||
val offsetAngle = atan2(offset.y, offset.x)
|
val offsetAngle = atan2(offset.y, offset.x)
|
||||||
scaleX =
|
scaleX =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||||
(width / height).fastCoerceAtMost(1f)
|
(width / height).fastCoerceAtMost(1f)
|
||||||
scaleY =
|
scaleY =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||||
(height / width).fastCoerceAtMost(1f)
|
(height / width).fastCoerceAtMost(1f)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||||
@@ -183,7 +188,12 @@ half4 main(float2 coord) {
|
|||||||
drawLayer(innerShadowLayer)
|
drawLayer(innerShadowLayer)
|
||||||
|
|
||||||
drawRect(
|
drawRect(
|
||||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||||
|
progress.coerceIn(
|
||||||
|
0.15f,
|
||||||
|
0.35f
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDrawFront = {
|
onDrawFront = {
|
||||||
@@ -197,7 +207,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -218,13 +231,18 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
// blur(24f, TileMode.Decal)
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.pointerInput(animationScope) {
|
.pointerInput(scope) {
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||||
}
|
}
|
||||||
@@ -232,7 +250,8 @@ half4 main(float2 coord) {
|
|||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
@@ -240,7 +259,10 @@ half4 main(float2 coord) {
|
|||||||
onDragEnd = { onDragStop() },
|
onDragEnd = { onDragStop() },
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -49,7 +49,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.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
@@ -57,17 +56,15 @@ 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
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
|
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
@@ -87,7 +84,7 @@ fun StyledScaffold(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
|
.padding(start = startPadding, end = endPadding)
|
||||||
) {
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
Box(
|
Box(
|
||||||
@@ -127,13 +124,12 @@ fun StyledScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content(topPadding + 64.dp, hazeState)
|
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -145,12 +141,11 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { _, _ ->
|
) { _, _, _->
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -162,7 +157,7 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerValue, _ ->
|
) { spacerValue, _, _ ->
|
||||||
content(spacerValue)
|
content(spacerValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -43,12 +43,13 @@ 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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
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
|
||||||
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.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
@@ -59,19 +60,10 @@ data class SelectItem(
|
|||||||
val iconRes: Int? = null,
|
val iconRes: Int? = null,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
|
val visible: Boolean = true,
|
||||||
val enabled: Boolean = true
|
val enabled: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SelectItem2(
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val iconRes: Int? = null,
|
|
||||||
val selected: () -> Boolean,
|
|
||||||
val onClick: () -> Unit,
|
|
||||||
val enabled: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSelectList(
|
fun StyledSelectList(
|
||||||
items: List<SelectItem>,
|
items: List<SelectItem>,
|
||||||
@@ -81,24 +73,27 @@ fun StyledSelectList(
|
|||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val visibleItems = items.filter { it.enabled }
|
val visibleItems = items.filter { it.visible }
|
||||||
visibleItems.forEachIndexed { index, item ->
|
visibleItems.forEachIndexed { index, item ->
|
||||||
val isFirst = index == 0
|
val isFirst = index == 0
|
||||||
val isLast = index == visibleItems.size - 1
|
val isLast = index == visibleItems.size - 1
|
||||||
val hasIcon = item.iconRes != null
|
val hasIcon = item.iconRes != null
|
||||||
|
|
||||||
val shape = when {
|
val shape = when {
|
||||||
|
isFirst && isLast -> RoundedCornerShape(28.dp)
|
||||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||||
else -> RoundedCornerShape(0.dp)
|
else -> RoundedCornerShape(0.dp)
|
||||||
}
|
}
|
||||||
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
|
var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -108,10 +103,18 @@ fun StyledSelectList(
|
|||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (item.enabled) {
|
||||||
tryAwaitRelease()
|
itemBackgroundColor =
|
||||||
itemBackgroundColor = backgroundColor
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
item.onClick()
|
tryAwaitRelease()
|
||||||
|
itemBackgroundColor = backgroundColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (item.enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
item.onClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,7 +124,7 @@ fun StyledSelectList(
|
|||||||
) {
|
) {
|
||||||
if (hasIcon) {
|
if (hasIcon) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(item.iconRes!!),
|
painter = painterResource(item.iconRes),
|
||||||
contentDescription = "Icon",
|
contentDescription = "Icon",
|
||||||
tint = Color(0xFF007AFF),
|
tint = Color(0xFF007AFF),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -181,4 +184,4 @@ fun StyledSelectList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableFloatState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||||
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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
|
||||||
@@ -81,7 +83,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
|||||||
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.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.InnerShadow
|
import com.kyant.backdrop.shadow.InnerShadow
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
@@ -203,10 +205,11 @@ class MomentumAnimation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnrememberedMutableState")
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSlider(
|
fun StyledSlider(
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
mutableFloatState: MutableFloatState,
|
value: Float,
|
||||||
onValueChange: (Float) -> Unit,
|
onValueChange: (Float) -> Unit,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||||
@@ -217,23 +220,26 @@ fun StyledSlider(
|
|||||||
startLabel: String? = null,
|
startLabel: String? = null,
|
||||||
endLabel: String? = null,
|
endLabel: String? = null,
|
||||||
independent: Boolean = false,
|
independent: Boolean = false,
|
||||||
description: String? = null
|
description: String? = null,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val isLightTheme = !isSystemInDarkTheme()
|
val isLightTheme = !isSystemInDarkTheme()
|
||||||
val accentColor =
|
|
||||||
if (isLightTheme) Color(0xFF0088FF)
|
|
||||||
else Color(0xFF0091FF)
|
|
||||||
val trackColor =
|
val trackColor =
|
||||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||||
else Color(0xFF787880).copy(0.36f)
|
else Color(0xFF787880).copy(0.36f)
|
||||||
|
val accentColor =
|
||||||
|
if (enabled) {
|
||||||
|
if (isLightTheme) Color(0xFF0088FF)
|
||||||
|
else Color(0xFF0091FF)
|
||||||
|
} else {
|
||||||
|
trackColor
|
||||||
|
}
|
||||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||||
|
|
||||||
val fraction by remember {
|
val fraction by derivedStateOf {
|
||||||
derivedStateOf {
|
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||||
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
.fastCoerceIn(0f, 1f)
|
||||||
.fastCoerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sliderBackdrop = rememberLayerBackdrop()
|
val sliderBackdrop = rememberLayerBackdrop()
|
||||||
@@ -242,6 +248,8 @@ fun StyledSlider(
|
|||||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var lastDragValue by remember { mutableFloatStateOf(value) }
|
||||||
|
|
||||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||||
|
|
||||||
@@ -427,71 +435,93 @@ fun StyledSlider(
|
|||||||
)
|
)
|
||||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||||
}
|
}
|
||||||
.draggable(
|
.then(
|
||||||
rememberDraggableState { delta ->
|
if (enabled) {
|
||||||
val trackWidth = trackWidthState.floatValue
|
Modifier
|
||||||
if (trackWidth > 0f) {
|
.draggable(
|
||||||
val targetFraction = fraction + delta / trackWidth
|
rememberDraggableState { delta ->
|
||||||
val targetValue =
|
val trackWidth = trackWidthState.floatValue
|
||||||
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
|
if (trackWidth > 0f) {
|
||||||
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
|
val targetFraction = fraction + delta / trackWidth
|
||||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
val targetValue =
|
||||||
targetValue,
|
lerp(
|
||||||
snapPoints,
|
valueRange.start,
|
||||||
snapThreshold
|
valueRange.endInclusive,
|
||||||
) else targetValue
|
targetFraction
|
||||||
onValueChange(snappedValue)
|
)
|
||||||
}
|
.fastCoerceIn(
|
||||||
},
|
valueRange.start,
|
||||||
Orientation.Horizontal,
|
valueRange.endInclusive
|
||||||
startDragImmediately = true,
|
)
|
||||||
onDragStarted = {
|
snapPoints.forEach { snap ->
|
||||||
// Remove this block as momentumAnimation handles pressing
|
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||||
},
|
(snap in targetValue..<lastDragValue)) {
|
||||||
onDragStopped = {
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
// Remove this block as momentumAnimation handles pressing
|
}
|
||||||
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
|
}
|
||||||
}
|
lastDragValue = targetValue
|
||||||
)
|
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||||
.then(momentumAnimation.modifier)
|
targetValue,
|
||||||
.drawBackdrop(
|
snapPoints,
|
||||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
snapThreshold,
|
||||||
{ RoundedCornerShape(28.dp) },
|
) else targetValue
|
||||||
highlight = {
|
onValueChange(snappedValue)
|
||||||
val progress = momentumAnimation.progress
|
}
|
||||||
Highlight.Ambient.copy(alpha = progress)
|
},
|
||||||
},
|
Orientation.Horizontal,
|
||||||
shadow = {
|
startDragImmediately = true,
|
||||||
Shadow(
|
onDragStarted = {
|
||||||
radius = 4f.dp,
|
lastDragValue = value
|
||||||
color = Color.Black.copy(0.05f)
|
},
|
||||||
)
|
onDragStopped = {
|
||||||
},
|
onValueChange((value * 100).roundToInt() / 100f)
|
||||||
innerShadow = {
|
}
|
||||||
val progress = momentumAnimation.progress
|
)
|
||||||
InnerShadow(
|
.then(momentumAnimation.modifier)
|
||||||
radius = 4f.dp * progress,
|
.drawBackdrop(
|
||||||
alpha = progress
|
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||||
)
|
{ RoundedCornerShape(28.dp) },
|
||||||
},
|
highlight = {
|
||||||
layerBlock = {
|
val progress = momentumAnimation.progress
|
||||||
scaleX = momentumAnimation.scaleX
|
Highlight.Ambient.copy(alpha = progress)
|
||||||
scaleY = momentumAnimation.scaleY
|
},
|
||||||
val velocity = momentumAnimation.velocity / 5000f
|
shadow = {
|
||||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
Shadow(
|
||||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
radius = 4f.dp,
|
||||||
},
|
color = Color.Black.copy(0.05f)
|
||||||
onDrawSurface = {
|
)
|
||||||
val progress = momentumAnimation.progress
|
},
|
||||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
innerShadow = {
|
||||||
},
|
val progress = momentumAnimation.progress
|
||||||
effects = {
|
InnerShadow(
|
||||||
val progress = momentumAnimation.progress
|
radius = 4f.dp * progress,
|
||||||
blur(8f.dp.toPx() * (1f - progress))
|
alpha = progress
|
||||||
refractionWithDispersion(
|
)
|
||||||
height = 6f.dp.toPx() * progress,
|
},
|
||||||
amount = size.height / 2f * progress
|
layerBlock = {
|
||||||
)
|
scaleX = momentumAnimation.scaleX
|
||||||
|
scaleY = momentumAnimation.scaleY
|
||||||
|
val velocity = momentumAnimation.velocity / 5000f
|
||||||
|
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||||
|
},
|
||||||
|
effects = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
blur(8f.dp.toPx() * (1f - progress))
|
||||||
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx() * progress,
|
||||||
|
refractionAmount = size.height / 2f * progress,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier.background(trackColor, RoundedCornerShape(28.dp))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.size(40f.dp, 24f.dp)
|
.size(40f.dp, 24f.dp)
|
||||||
@@ -566,12 +596,13 @@ fun StyledSliderPreview() {
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
Box (
|
Column (
|
||||||
Modifier.align(Alignment.Center)
|
Modifier.align(Alignment.Center),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
mutableFloatState = a,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
a.floatValue = it
|
a.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -582,6 +613,19 @@ fun StyledSliderPreview() {
|
|||||||
startIcon = "A",
|
startIcon = "A",
|
||||||
endIcon = "B",
|
endIcon = "B",
|
||||||
)
|
)
|
||||||
|
StyledSlider(
|
||||||
|
value = a.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
a.floatValue = it
|
||||||
|
},
|
||||||
|
valueRange = 0f..2f,
|
||||||
|
snapPoints = listOf(1f),
|
||||||
|
snapThreshold = 0.1f,
|
||||||
|
independent = true,
|
||||||
|
startIcon = "A",
|
||||||
|
endIcon = "B",
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.animation.Animatable
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||||
import androidx.compose.ui.graphics.layer.drawLayer
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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.util.fastCoerceIn
|
import androidx.compose.ui.util.fastCoerceIn
|
||||||
@@ -68,11 +70,12 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||||
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.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSwitch(
|
fun StyledSwitch(
|
||||||
@@ -81,9 +84,12 @@ fun StyledSwitch(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
|
||||||
|
0x805B5B5E
|
||||||
|
) else Color(0xFFD1D1D6)
|
||||||
|
|
||||||
val trackWidth = 64.dp
|
val trackWidth = 64.dp
|
||||||
val trackHeight = 28.dp
|
val trackHeight = 28.dp
|
||||||
@@ -98,24 +104,25 @@ fun StyledSwitch(
|
|||||||
val animatedFraction = remember { Animatable(fraction) }
|
val animatedFraction = remember { Animatable(fraction) }
|
||||||
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val animationScope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
|
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||||
compositingStrategy = CompositingStrategy.Offscreen
|
compositingStrategy = CompositingStrategy.Offscreen
|
||||||
}
|
}
|
||||||
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
|
val targetColor = if (checked) onColor else offColor
|
||||||
|
val animatedTrackColor by animateColorAsState(targetColor)
|
||||||
val totalDrag = remember { mutableFloatStateOf(0f) }
|
val totalDrag = remember { mutableFloatStateOf(0f) }
|
||||||
val tapThreshold = 10f
|
val tapThreshold = 10f
|
||||||
val isFirstComposition = remember { mutableStateOf(true) }
|
val isFirstComposition = remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(checked) {
|
LaunchedEffect(checked) {
|
||||||
if (!isFirstComposition.value) {
|
if (!isFirstComposition.value) {
|
||||||
|
if (checked) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||||
|
} else {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||||
|
}
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch {
|
|
||||||
val targetColor = if (checked) onColor else offColor
|
|
||||||
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
|
|
||||||
}
|
|
||||||
launch {
|
launch {
|
||||||
val targetFrac = if (checked) 1f else 0f
|
val targetFrac = if (checked) 1f else 0f
|
||||||
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
||||||
@@ -140,7 +147,7 @@ fun StyledSwitch(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(switchBackdrop)
|
.layerBackdrop(switchBackdrop)
|
||||||
.clip(RoundedCornerShape(trackHeight / 2))
|
.clip(RoundedCornerShape(trackHeight / 2))
|
||||||
.background(animatedTrackColor.value)
|
.background(animatedTrackColor)
|
||||||
.width(trackWidth)
|
.width(trackWidth)
|
||||||
.height(trackHeight)
|
.height(trackHeight)
|
||||||
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
||||||
@@ -154,27 +161,31 @@ fun StyledSwitch(
|
|||||||
.then(if (enabled) Modifier.draggable(
|
.then(if (enabled) Modifier.draggable(
|
||||||
rememberDraggableState { delta ->
|
rememberDraggableState { delta ->
|
||||||
if (trackWidthPx.floatValue > 0f) {
|
if (trackWidthPx.floatValue > 0f) {
|
||||||
|
val oldFraction = animatedFraction.value
|
||||||
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
animatedFraction.snapTo(newFraction)
|
animatedFraction.snapTo(newFraction)
|
||||||
}
|
}
|
||||||
totalDrag.floatValue += kotlin.math.abs(delta)
|
totalDrag.floatValue += abs(delta)
|
||||||
val newChecked = newFraction >= 0.5f
|
val newChecked = newFraction >= 0.5f
|
||||||
if (newChecked != checked) {
|
if (newChecked != checked) {
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
}
|
}
|
||||||
|
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Orientation.Horizontal,
|
Orientation.Horizontal,
|
||||||
startDragImmediately = true,
|
startDragImmediately = true,
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
totalDrag.floatValue = 0f
|
totalDrag.floatValue = 0f
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
progressAnimation.animateTo(1f, progressAnimationSpec)
|
progressAnimation.animateTo(1f, progressAnimationSpec)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
if (totalDrag.floatValue < tapThreshold) {
|
if (totalDrag.floatValue < tapThreshold) {
|
||||||
val newChecked = !checked
|
val newChecked = !checked
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
@@ -262,7 +273,12 @@ fun StyledSwitch(
|
|||||||
drawRect(Color.White.copy(1f - progress))
|
drawRect(Color.White.copy(1f - progress))
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.width(thumbWidth)
|
.width(thumbWidth)
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StyledToggle(
|
||||||
|
title: String? = null,
|
||||||
|
label: String,
|
||||||
|
description: String? = null,
|
||||||
|
checked: Boolean = false,
|
||||||
|
independent: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val currentChecked by rememberUpdatedState(checked)
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var backgroundColor by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatedBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = backgroundColor,
|
||||||
|
animationSpec = tween(durationMillis = 500)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (independent) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(4.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
if (enabled) {
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(55.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.background(
|
||||||
|
if (isDarkTheme) Color(0xFF000000)
|
||||||
|
else Color(0xFFF2F2F7)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val isPressed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
isPressed.value = true
|
||||||
|
tryAwaitRelease()
|
||||||
|
isPressed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun StyledTogglePreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
StyledToggle(
|
||||||
|
label = "Example Toggle",
|
||||||
|
description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
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.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
|||||||
import androidx.dynamicanimation.animation.SpringAnimation
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
import androidx.dynamicanimation.animation.SpringForce
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
|
|
||||||
containerView.addView(islandView, containerParams)
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
params = WindowManager.LayoutParams(
|
params = WindowManager.LayoutParams(
|
||||||
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
|
|||||||
videoView.start()
|
videoView.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
windowManager.addView(containerView, params)
|
try {
|
||||||
|
windowManager.addView(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
islandView.post {
|
islandView.post {
|
||||||
initialHeight = islandView.height
|
initialHeight = islandView.height
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
|
||||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||||
class PopupWindow(
|
class PopupWindow(
|
||||||
@@ -64,10 +64,16 @@ class PopupWindow(
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
val displayMetrics = context.resources.displayMetrics
|
||||||
|
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
|
||||||
|
width = if (screenWidthDp >= 600) {
|
||||||
|
(400 * displayMetrics.density).toInt()
|
||||||
|
} else {
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||||
format = PixelFormat.TRANSLUCENT
|
format = PixelFormat.TRANSLUCENT
|
||||||
gravity = Gravity.BOTTOM
|
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||||
dimAmount = 0.3f
|
dimAmount = 0.3f
|
||||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||||
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
||||||
@@ -84,7 +90,6 @@ class PopupWindow(
|
|||||||
mParams.x = 0
|
mParams.x = 0
|
||||||
mParams.y = 0
|
mParams.y = 0
|
||||||
|
|
||||||
mParams.gravity = Gravity.BOTTOM
|
|
||||||
mView.setOnClickListener {
|
mView.setOnClickListener {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -139,7 +144,11 @@ class PopupWindow(
|
|||||||
vid.start()
|
vid.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
mWindowManager.addView(mView, mParams)
|
try {
|
||||||
|
mWindowManager.addView(mView, mParams)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
val displayMetrics = mView.context.resources.displayMetrics
|
val displayMetrics = mView.context.resources.displayMetrics
|
||||||
val screenHeight = displayMetrics.heightPixels
|
val screenHeight = displayMetrics.heightPixels
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInParent
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledDropdown
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
private var phoneMediaDebounceJob: Job? = null
|
||||||
|
private var toneVolumeDebounceJob: Job? = null
|
||||||
|
//private const val TAG = "AccessibilitySettings"
|
||||||
|
|
||||||
|
@SuppressLint("DefaultLocale")
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val hearingAidEnabled =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||||
|
1
|
||||||
|
)
|
||||||
|
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 1
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.accessibility)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
|
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||||
|
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val pressSpeedOptions = mapOf(
|
||||||
|
0.toByte() to stringResource(R.string.default_option),
|
||||||
|
1.toByte() to stringResource(R.string.slower),
|
||||||
|
2.toByte() to stringResource(R.string.slowest)
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedPressSpeedValue =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
var selectedPressSpeed by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pressAndHoldDurationOptions = mapOf(
|
||||||
|
0.toByte() to stringResource(R.string.default_option),
|
||||||
|
1.toByte() to stringResource(R.string.slower),
|
||||||
|
2.toByte() to stringResource(R.string.slowest)
|
||||||
|
)
|
||||||
|
|
||||||
|
val selectedPressAndHoldDurationValue =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
var selectedPressAndHoldDuration by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||||
|
?: pressAndHoldDurationOptions[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val volumeSwipeSpeedOptions = mapOf(
|
||||||
|
1.toByte() to stringResource(R.string.default_option),
|
||||||
|
2.toByte() to stringResource(R.string.longer),
|
||||||
|
3.toByte() to stringResource(R.string.longest)
|
||||||
|
)
|
||||||
|
val selectedVolumeSwipeSpeedValue =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
var selectedVolumeSwipeSpeed by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||||
|
?: volumeSwipeSpeedOptions[1]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
|
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||||
|
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||||
|
phoneMediaDebounceJob?.cancel()
|
||||||
|
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(150)
|
||||||
|
try {
|
||||||
|
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||||
|
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||||
|
Log.d(
|
||||||
|
"AccessibilitySettingsScreen",
|
||||||
|
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||||
|
)
|
||||||
|
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(
|
||||||
|
"AccessibilitySettingsScreen",
|
||||||
|
"Error sending phone/media EQ: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
DropdownMenuComponent(
|
||||||
|
label = stringResource(R.string.press_speed),
|
||||||
|
description = stringResource(R.string.press_speed_description),
|
||||||
|
options = pressSpeedOptions.values.toList(),
|
||||||
|
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
|
||||||
|
onOptionSelected = { newValue ->
|
||||||
|
selectedPressSpeed = newValue
|
||||||
|
viewModel.setControlCommandByte(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
|
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||||
|
?: 0.toByte()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textColor = textColor,
|
||||||
|
hazeState = hazeState,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
DropdownMenuComponent(
|
||||||
|
label = stringResource(R.string.press_and_hold_duration),
|
||||||
|
description = stringResource(R.string.press_and_hold_duration_description),
|
||||||
|
options = pressAndHoldDurationOptions.values.toList(),
|
||||||
|
selectedOption = selectedPressAndHoldDuration
|
||||||
|
?: stringResource(R.string.default_option),
|
||||||
|
onOptionSelected = { newValue ->
|
||||||
|
selectedPressAndHoldDuration = newValue
|
||||||
|
viewModel.setControlCommandByte(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||||
|
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||||
|
?: 0.toByte()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textColor = textColor,
|
||||||
|
hazeState = hazeState,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.noise_control),
|
||||||
|
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||||
|
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||||
|
independent = true,
|
||||||
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte(),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "transparency_customization",
|
||||||
|
name = stringResource(R.string.customize_transparency_mode),
|
||||||
|
navController = navController,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toneVolumeValue =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toFloat() ?: 75f
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.tone_volume),
|
||||||
|
description = stringResource(R.string.tone_volume_description),
|
||||||
|
value = toneVolumeValue,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.setControlCommandValue(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
|
||||||
|
byteArrayOf(it.toInt().toByte(), 0x50)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(75f),
|
||||||
|
startIcon = "\uDBC0\uDEA1",
|
||||||
|
endIcon = "\uDBC0\uDEA9",
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||||
|
val volumeSwipeEnabled =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 0x01
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.volume_control),
|
||||||
|
description = stringResource(R.string.volume_control_description),
|
||||||
|
checked = volumeSwipeEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.then(
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
DropdownMenuComponent(
|
||||||
|
label = stringResource(R.string.volume_swipe_speed),
|
||||||
|
description = stringResource(R.string.volume_swipe_speed_description),
|
||||||
|
options = volumeSwipeSpeedOptions.values.toList(),
|
||||||
|
selectedOption = selectedVolumeSwipeSpeed
|
||||||
|
?: stringResource(R.string.default_option),
|
||||||
|
onOptionSelected = { newValue ->
|
||||||
|
selectedVolumeSwipeSpeed = newValue
|
||||||
|
viewModel.setControlCommandByte(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||||
|
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||||
|
?: 1.toByte()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
textColor = textColor,
|
||||||
|
hazeState = hazeState,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
|
||||||
|
// Text(
|
||||||
|
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
|
||||||
|
// fontSize = 14.sp,
|
||||||
|
// fontWeight = FontWeight.Bold,
|
||||||
|
// color = textColor.copy(alpha = 0.6f),
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||||
|
// )
|
||||||
|
// Column(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .background(
|
||||||
|
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||||
|
// RoundedCornerShape(28.dp)
|
||||||
|
// )
|
||||||
|
// .padding(vertical = 0.dp)
|
||||||
|
// ) {
|
||||||
|
// val darkModeLocal = isSystemInDarkTheme()
|
||||||
|
//
|
||||||
|
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||||
|
// var phoneBackgroundColor by remember {
|
||||||
|
// mutableStateOf(
|
||||||
|
// if (darkModeLocal) Color(
|
||||||
|
// 0xFF1C1C1E
|
||||||
|
// ) else Color(0xFFFFFFFF)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// val phoneAnimatedBackgroundColor by animateColorAsState(
|
||||||
|
// targetValue = phoneBackgroundColor,
|
||||||
|
// animationSpec = tween(durationMillis = 500)
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// Row(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .height(48.dp)
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .background(phoneAnimatedBackgroundColor, phoneShape)
|
||||||
|
// .pointerInput(Unit) {
|
||||||
|
// detectTapGestures(
|
||||||
|
// onPress = {
|
||||||
|
// phoneBackgroundColor =
|
||||||
|
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||||
|
// 0x40D9D9D9
|
||||||
|
// )
|
||||||
|
// tryAwaitRelease()
|
||||||
|
// phoneBackgroundColor =
|
||||||
|
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||||
|
// 0xFFFFFFFF
|
||||||
|
// )
|
||||||
|
// phoneEQEnabled.value = !phoneEQEnabled.value
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// .padding(horizontal = 16.dp),
|
||||||
|
// verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Text(
|
||||||
|
// stringResource(R.string.phone),
|
||||||
|
// fontSize = 16.sp,
|
||||||
|
// color = textColor,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// modifier = Modifier.weight(1f)
|
||||||
|
// )
|
||||||
|
// Checkbox(
|
||||||
|
// checked = phoneEQEnabled.value,
|
||||||
|
// onCheckedChange = { phoneEQEnabled.value = it },
|
||||||
|
// colors = CheckboxDefaults.colors().copy(
|
||||||
|
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||||
|
// uncheckedCheckmarkColor = Color.Transparent,
|
||||||
|
// checkedBoxColor = Color.Transparent,
|
||||||
|
// uncheckedBoxColor = Color.Transparent,
|
||||||
|
// checkedBorderColor = Color.Transparent,
|
||||||
|
// uncheckedBorderColor = Color.Transparent
|
||||||
|
// ),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .height(24.dp)
|
||||||
|
// .scale(1.5f)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// HorizontalDivider(
|
||||||
|
// thickness = 1.dp, color = Color(0x40888888)
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||||
|
// var mediaBackgroundColor by remember {
|
||||||
|
// mutableStateOf(
|
||||||
|
// if (darkModeLocal) Color(
|
||||||
|
// 0xFF1C1C1E
|
||||||
|
// ) else Color(0xFFFFFFFF)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// val mediaAnimatedBackgroundColor by animateColorAsState(
|
||||||
|
// targetValue = mediaBackgroundColor,
|
||||||
|
// animationSpec = tween(durationMillis = 500)
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// Row(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .height(48.dp)
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .background(mediaAnimatedBackgroundColor, mediaShape)
|
||||||
|
// .pointerInput(Unit) {
|
||||||
|
// detectTapGestures(
|
||||||
|
// onPress = {
|
||||||
|
// mediaBackgroundColor =
|
||||||
|
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||||
|
// 0x40D9D9D9
|
||||||
|
// )
|
||||||
|
// tryAwaitRelease()
|
||||||
|
// mediaBackgroundColor =
|
||||||
|
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||||
|
// 0xFFFFFFFF
|
||||||
|
// )
|
||||||
|
// mediaEQEnabled.value = !mediaEQEnabled.value
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// .padding(horizontal = 16.dp),
|
||||||
|
// verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Text(
|
||||||
|
// stringResource(R.string.media),
|
||||||
|
// fontSize = 16.sp,
|
||||||
|
// color = textColor,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// modifier = Modifier.weight(1f)
|
||||||
|
// )
|
||||||
|
// Checkbox(
|
||||||
|
// checked = mediaEQEnabled.value,
|
||||||
|
// onCheckedChange = { mediaEQEnabled.value = it },
|
||||||
|
// colors = CheckboxDefaults.colors().copy(
|
||||||
|
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||||
|
// uncheckedCheckmarkColor = Color.Transparent,
|
||||||
|
// checkedBoxColor = Color.Transparent,
|
||||||
|
// uncheckedBoxColor = Color.Transparent,
|
||||||
|
// checkedBorderColor = Color.Transparent,
|
||||||
|
// uncheckedBorderColor = Color.Transparent
|
||||||
|
// ),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .height(24.dp)
|
||||||
|
// .scale(1.5f)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//// EQ Settings. Don't seem to have an effect?
|
||||||
|
// Column(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .background(
|
||||||
|
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||||
|
// RoundedCornerShape(28.dp)
|
||||||
|
// )
|
||||||
|
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
// ) {
|
||||||
|
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||||
|
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||||
|
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||||
|
//
|
||||||
|
// 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)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@Composable
|
||||||
|
private fun DropdownMenuComponent(
|
||||||
|
label: String,
|
||||||
|
options: List<String>,
|
||||||
|
selectedOption: String,
|
||||||
|
onOptionSelected: (String) -> Unit,
|
||||||
|
textColor: Color,
|
||||||
|
hazeState: HazeState,
|
||||||
|
description: String? = null,
|
||||||
|
independent: Boolean = true
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||||
|
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||||
|
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var parentDragActive by remember { mutableStateOf(false) }
|
||||||
|
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (independent) {
|
||||||
|
if (description != null) {
|
||||||
|
Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.padding(vertical = 8.dp)
|
||||||
|
}
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
|
||||||
|
0xFFFFFFFF
|
||||||
|
)) else Color.Transparent,
|
||||||
|
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||||
|
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
|
||||||
|
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 12.dp, end = 12.dp)
|
||||||
|
.height(58.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (expanded) {
|
||||||
|
expanded = false
|
||||||
|
lastDismissTime = now
|
||||||
|
} else {
|
||||||
|
if (now - lastDismissTime > 250L) {
|
||||||
|
touchOffset = offset
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectDragGesturesAfterLongPress(onDragStart = { offset ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
touchOffset = offset
|
||||||
|
if (!expanded && now - lastDismissTime > 250L) {
|
||||||
|
expanded = true
|
||||||
|
}
|
||||||
|
lastDismissTime = now
|
||||||
|
parentDragActive = true
|
||||||
|
parentHoveredIndex = 0
|
||||||
|
}, onDrag = { change, _ ->
|
||||||
|
val current = change.position
|
||||||
|
val touch = touchOffset ?: current
|
||||||
|
val posInPopupY = current.y - touch.y
|
||||||
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdx) {
|
||||||
|
scope.launch {
|
||||||
|
haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentHoveredIndex = idx
|
||||||
|
previousIdx = idx
|
||||||
|
}, onDragEnd = {
|
||||||
|
parentDragActive = false
|
||||||
|
parentHoveredIndex?.let { idx ->
|
||||||
|
if (idx in options.indices) {
|
||||||
|
onOptionSelected(options[idx])
|
||||||
|
expanded = false
|
||||||
|
lastDismissTime = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
|
||||||
|
scope.launch {
|
||||||
|
haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.GestureEnd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parentHoveredIndex = null
|
||||||
|
}, onDragCancel = {
|
||||||
|
parentDragActive = false
|
||||||
|
parentHoveredIndex = null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
if (!independent && description != null) {
|
||||||
|
Text(
|
||||||
|
text = description, style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||||
|
boxPosition = coordinates.positionInParent()
|
||||||
|
}) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = selectedOption, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor.copy(alpha = 0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "", style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(start = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledDropdown(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = {
|
||||||
|
expanded = false
|
||||||
|
lastDismissTime = System.currentTimeMillis()
|
||||||
|
},
|
||||||
|
options = options,
|
||||||
|
selectedOption = selectedOption,
|
||||||
|
touchOffset = touchOffset,
|
||||||
|
boxPosition = boxPosition,
|
||||||
|
externalHoveredIndex = parentHoveredIndex,
|
||||||
|
externalDragActive = parentDragActive,
|
||||||
|
onOptionSelected = { option ->
|
||||||
|
onOptionSelected(option)
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
hazeState = hazeState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (independent && description != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.background(
|
||||||
|
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = description, style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
alpha = 0.6f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.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.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 kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.customize_adaptive_audio)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sliderValue = remember {
|
||||||
|
mutableFloatStateOf(
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toFloat() ?: 50f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var job by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.customize_adaptive_audio),
|
||||||
|
value = sliderValue.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
sliderValue.floatValue = it
|
||||||
|
job?.cancel()
|
||||||
|
job = scope.launch {
|
||||||
|
delay(150)
|
||||||
|
viewModel.setControlCommandValue(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||||
|
byteArrayOf((100 - it).toInt().toByte())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(0f, 50f, 100f),
|
||||||
|
startIcon = "",
|
||||||
|
endIcon = "",
|
||||||
|
independent = true,
|
||||||
|
description = stringResource(R.string.adaptive_audio_description),
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
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.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsPro3
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AboutCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AudioSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.BatteryView
|
||||||
|
import me.kavishdevar.librepods.presentation.components.CallControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
|
@Composable
|
||||||
|
fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
var deviceName by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
TextFieldValue(
|
||||||
|
sharedPreferences.getString("name", state.deviceName).toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameChangeListener = remember {
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
if (key == "name") {
|
||||||
|
deviceName =
|
||||||
|
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
onDispose {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.refreshInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
isSystemInDarkTheme()
|
||||||
|
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = deviceName.text, actionButtons = listOf(
|
||||||
|
{ scaffoldBackdrop ->
|
||||||
|
StyledIconButton(
|
||||||
|
onClick = { navController.navigate("app_settings") },
|
||||||
|
icon = "",
|
||||||
|
backdrop = scaffoldBackdrop
|
||||||
|
)
|
||||||
|
}), snackbarHostState = snackbarHostState
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
hazeStateS.value = hazeState
|
||||||
|
var blockTouches by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.demoActivated.collect {
|
||||||
|
blockTouches = true
|
||||||
|
delay(1000)
|
||||||
|
blockTouches = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLocallyConnected) {
|
||||||
|
val capabilities = state.capabilities
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(if (blockTouches) Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
||||||
|
item(key = "battery") {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = state.battery,
|
||||||
|
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
|
||||||
|
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
|
||||||
|
item(key = "name") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "rename",
|
||||||
|
name = stringResource(R.string.name),
|
||||||
|
currentState = deviceName.text,
|
||||||
|
navController = navController,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasHearingAidCapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
|
||||||
|
val hasPPECapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
|
||||||
|
|
||||||
|
if (hasHearingAidCapability || hasPPECapability) {
|
||||||
|
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
|
||||||
|
key = "spacer_hearing_health"
|
||||||
|
) { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
|
item(key = "hearing_health") {
|
||||||
|
HearingHealthSettings(
|
||||||
|
navController = navController,
|
||||||
|
hasPPECapability = hasPPECapability,
|
||||||
|
hasHearingAidCapability = hasHearingAidCapability,
|
||||||
|
vendorIdHook = state.vendorIdHook
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
||||||
|
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "noise_control") {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = state.offListeningMode,
|
||||||
|
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() ?: 3,
|
||||||
|
onNoiseControlModeChanged = {
|
||||||
|
viewModel.setControlCommandInt(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||||
|
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "press_hold") {
|
||||||
|
PressAndHoldSettings(
|
||||||
|
navController = navController,
|
||||||
|
leftAction = state.leftAction,
|
||||||
|
rightAction = state.rightAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "call_control") {
|
||||||
|
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||||
|
val flipped = bytes[1] == 0x02.toByte()
|
||||||
|
CallControlSettings(
|
||||||
|
hazeState = hazeState,
|
||||||
|
flipped = flipped,
|
||||||
|
onCallControlValueChanged = {
|
||||||
|
viewModel.setControlCommandValue(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||||
|
if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
|
||||||
|
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
// item(key = "camera_control") {
|
||||||
|
// NavigationButton(
|
||||||
|
// to = "camera_control",
|
||||||
|
// name = stringResource(R.string.camera_remote),
|
||||||
|
// description = stringResource(R.string.camera_control_description),
|
||||||
|
// title = stringResource(R.string.camera_control),
|
||||||
|
// navController = navController
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
item(key = "upgrade_button") {
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
|
||||||
|
0xFFE59900
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "audio") {
|
||||||
|
val model = state.instance?.model ?: AirPodsPro3()
|
||||||
|
val adaptiveVolumeCapability =
|
||||||
|
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
|
||||||
|
val conversationalAwarenessCapability =
|
||||||
|
model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
|
||||||
|
val loudSoundReductionCapability =
|
||||||
|
model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
|
||||||
|
val adaptiveAudioCapability =
|
||||||
|
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
|
||||||
|
|
||||||
|
val adaptiveVolumeChecked =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
val conversationalAwarenessChecked =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
|
||||||
|
AudioSettings(
|
||||||
|
navController = navController,
|
||||||
|
adaptiveVolumeCapability = adaptiveVolumeCapability,
|
||||||
|
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
||||||
|
loudSoundReductionCapability = loudSoundReductionCapability,
|
||||||
|
adaptiveAudioCapability = adaptiveAudioCapability,
|
||||||
|
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
||||||
|
onAdaptiveVolumeCheckedChange = { checked ->
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
|
||||||
|
onConversationalAwarenessCheckedChange = { checked ->
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loudSoundReductionChecked = state.loudSoundReductionEnabled,
|
||||||
|
onLoudSoundReductionCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
vendorIdHook = state.vendorIdHook,
|
||||||
|
isPremium = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "connection") {
|
||||||
|
ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
|
||||||
|
onAutomaticEarDetectionChanged = {
|
||||||
|
viewModel.setAutomaticEarDetectionEnabled(it)
|
||||||
|
},
|
||||||
|
automaticConnectionEnabled = state.automaticConnectionEnabled,
|
||||||
|
onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "microphone") {
|
||||||
|
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||||
|
MicrophoneSettings(
|
||||||
|
hazeState = hazeState,
|
||||||
|
micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
|
||||||
|
onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
||||||
|
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "sleep_detection") {
|
||||||
|
val id =
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.sleep_detection),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(id, it)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
||||||
|
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "head_tracking") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "head_tracking",
|
||||||
|
name = stringResource(R.string.head_gestures),
|
||||||
|
navController = navController,
|
||||||
|
currentState = if (sharedPreferences.getBoolean(
|
||||||
|
"head_gestures", false
|
||||||
|
)
|
||||||
|
) stringResource(R.string.on) else stringResource(R.string.off)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "accessibility") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "accessibility",
|
||||||
|
name = stringResource(R.string.accessibility),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
||||||
|
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "off_listening") {
|
||||||
|
val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.off_listening_mode),
|
||||||
|
description = stringResource(R.string.off_listening_mode_description),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = viewModel::setOffListeningMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
item(key = "about") {
|
||||||
|
AboutCard(
|
||||||
|
navController = navController,
|
||||||
|
modelName = state.modelName,
|
||||||
|
actualModel = state.actualModel,
|
||||||
|
serialNumbers = state.serialNumbers,
|
||||||
|
version = state.version3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
||||||
|
item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawBackdrop(
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
exportedBackdrop = backdrop,
|
||||||
|
shape = { RoundedCornerShape(0.dp) },
|
||||||
|
highlight = {
|
||||||
|
Highlight.Ambient.copy(alpha = 0f)
|
||||||
|
},
|
||||||
|
effects = {})
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
val tapCount = remember { mutableIntStateOf(0) }
|
||||||
|
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (now - lastTapTime.longValue > 400) {
|
||||||
|
tapCount.intValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
tapCount.intValue++
|
||||||
|
lastTapTime.longValue = now
|
||||||
|
|
||||||
|
if (tapCount.intValue >= 5) {
|
||||||
|
tapCount.intValue = 0
|
||||||
|
viewModel.activateDemoMode()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.airpods_not_connected), style = TextStyle(
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.airpods_not_connected_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = { navController.navigate("troubleshooting") },
|
||||||
|
backdrop = backdrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.troubleshooting),
|
||||||
|
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 = {
|
||||||
|
viewModel.reconnectFromSavedMac()
|
||||||
|
}, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reconnect_to_last_device), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppSettingsScreen(
|
||||||
|
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.settings)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
if (!state.isPremium && state.connectionSuccessful) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.connectionSuccessful) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.widget),
|
||||||
|
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||||
|
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||||
|
checked = state.showPhoneBatteryInWidget,
|
||||||
|
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||||
|
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||||
|
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||||
|
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||||
|
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||||
|
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||||
|
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||||
|
LaunchedEffect(conversationalAwarenessVolume) {
|
||||||
|
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.conversational_awareness_volume),
|
||||||
|
value = conversationalAwarenessVolume,
|
||||||
|
valueRange = 10f..85f,
|
||||||
|
snapPoints = listOf(44f),
|
||||||
|
startLabel = "10%",
|
||||||
|
endLabel = "85%",
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
viewModel.setConversationalAwarenessVolume(
|
||||||
|
newValue
|
||||||
|
)
|
||||||
|
},
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
// if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
// Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
//
|
||||||
|
// NavigationButton(
|
||||||
|
// to = "",
|
||||||
|
// title = stringResource(R.string.camera_control),
|
||||||
|
// name = stringResource(R.string.set_custom_camera_package),
|
||||||
|
// navController = navController,
|
||||||
|
// onClick = {
|
||||||
|
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||||
|
// },
|
||||||
|
// independent = true,
|
||||||
|
// description = stringResource(R.string.camera_control_app_description)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.ear_detection),
|
||||||
|
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||||
|
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||||
|
checked = state.disconnectWhenNotWearing,
|
||||||
|
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_disconnected),
|
||||||
|
description = stringResource(R.string.takeover_disconnected_desc),
|
||||||
|
checked = state.takeoverWhenDisconnected,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_idle),
|
||||||
|
description = stringResource(R.string.takeover_idle_desc),
|
||||||
|
checked = state.takeoverWhenIdle,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_music),
|
||||||
|
description = stringResource(R.string.takeover_music_desc),
|
||||||
|
checked = state.takeoverWhenMusic,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_call),
|
||||||
|
description = stringResource(R.string.takeover_call_desc),
|
||||||
|
checked = state.takeoverWhenCall,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_ringing_call),
|
||||||
|
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||||
|
checked = state.takeoverWhenRingingCall,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_media_start),
|
||||||
|
description = stringResource(R.string.takeover_media_start_desc),
|
||||||
|
checked = state.takeoverWhenMediaStart,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_options), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||||
|
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||||
|
checked = state.useAlternateHeadTrackingPackets,
|
||||||
|
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations_unavailable),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
val restartBluetoothText =
|
||||||
|
stringResource(R.string.found_offset_restart_bluetooth)
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||||
|
stringResource(
|
||||||
|
R.string.requires_xposed
|
||||||
|
)
|
||||||
|
})",
|
||||||
|
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||||
|
checked = state.vendorIdHook,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.setVendorIdHook(enabled)
|
||||||
|
},
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
NavigationButton(
|
||||||
|
to = "troubleshooting",
|
||||||
|
name = stringResource(R.string.troubleshooting),
|
||||||
|
navController = navController,
|
||||||
|
independent = true,
|
||||||
|
description = stringResource(R.string.troubleshooting_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.contact), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.email),
|
||||||
|
navController = navController,
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:".toUri()
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: <SUBJECT>")
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
"Describe your issue here:" +
|
||||||
|
"\n\n\n\n----------" +
|
||||||
|
"\nPhone details:" +
|
||||||
|
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
|
||||||
|
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
|
||||||
|
"\nDISPLAY_VERSION: ${Build.DISPLAY} (${Build.PRODUCT})" +
|
||||||
|
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
|
||||||
|
"\n\nApp details:" +
|
||||||
|
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
|
||||||
|
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
|
||||||
|
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
|
||||||
|
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.discord),
|
||||||
|
navController = navController,
|
||||||
|
onClick = {
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.github_issues),
|
||||||
|
navController = navController,
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://github.com/kavishdevar/librepods/issues".toUri()
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
DeviceInfoCard()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
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.version), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.VERSION_NAME, 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.version_code), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.VERSION_CODE.toString(), 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.flavor), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.FLAVOR, 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.build_type), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.BUILD_TYPE,
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(bottomPadding))
|
||||||
|
|
||||||
|
if (state.showCameraDialog) {
|
||||||
|
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.set_custom_camera_package),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}, text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.enter_custom_camera_package),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.cameraPackageValue,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.setCameraPackageValue(it)
|
||||||
|
viewModel.setCameraPackageError(null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = state.cameraPackageError != null,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
capitalization = KeyboardCapitalization.None
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
|
||||||
|
0xFF3C6DF5
|
||||||
|
),
|
||||||
|
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||||
|
),
|
||||||
|
supportingText = {
|
||||||
|
if (state.cameraPackageError != null) {
|
||||||
|
Text(
|
||||||
|
state.cameraPackageError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.custom_camera_package)) })
|
||||||
|
}
|
||||||
|
}, confirmButton = {
|
||||||
|
val successText = stringResource(R.string.custom_camera_package_set_success)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveCameraPackage()
|
||||||
|
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
"Save",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.setShowCameraDialog(false) }) {
|
||||||
|
Text(
|
||||||
|
"Cancel",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
|
import me.kavishdevar.librepods.services.AppListenerService
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||||
|
|
||||||
|
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||||
|
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||||
|
val enabledServices =
|
||||||
|
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||||
|
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||||
|
return enabledServices.any {
|
||||||
|
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
|
||||||
|
it.resolveInfo.serviceInfo.name == serviceComponent.className
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSelection(action: StemPressType?) {
|
||||||
|
if (action != null && !isAppListenerServiceEnabled(context)) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
|
} else {
|
||||||
|
viewModel.setCameraAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraOptions = remember(currentCameraAction) {
|
||||||
|
listOf(
|
||||||
|
SelectItem(
|
||||||
|
name = "Off",
|
||||||
|
selected = currentCameraAction == null,
|
||||||
|
onClick = { handleSelection(null) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press once",
|
||||||
|
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press and hold AirPods",
|
||||||
|
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.LONG_PRESS) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.camera_control)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
StyledSelectList(items = cameraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -335,12 +335,11 @@ fun DebugScreen(navController: NavController) {
|
|||||||
expandedItems.value = emptySet()
|
expandedItems.value = emptySet()
|
||||||
},
|
},
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -349,7 +348,7 @@ fun DebugScreen(navController: NavController) {
|
|||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1,29 +1,33 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.graphics.Paint
|
||||||
import android.os.Build
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -42,6 +46,7 @@ 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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -71,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.drawText
|
import androidx.compose.ui.text.drawText
|
||||||
@@ -80,7 +84,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.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -92,12 +95,13 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
@@ -105,14 +109,14 @@ import kotlin.math.sin
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HeadTrackingScreen(navController: NavController) {
|
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
onDispose {
|
onDispose {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -125,25 +129,22 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
title = stringResource(R.string.head_tracking),
|
title = stringResource(R.string.head_tracking),
|
||||||
actionButtons = listOf(
|
actionButtons = listOf(
|
||||||
{ scaffoldBackdrop ->
|
{ scaffoldBackdrop ->
|
||||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
if (!state.headTrackingActive) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||||
} else {
|
} else {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = if (isActive) "" else "",
|
icon = if (state.headTrackingActive) "" else "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, _ ->
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
var gestureText by remember { mutableStateOf("") }
|
var gestureText by remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -164,35 +165,49 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = "Head Gestures",
|
label = "Head Gestures",
|
||||||
sharedPreferences = sharedPreferences,
|
checked = state.headGesturesEnabled,
|
||||||
sharedPreferenceKey = "head_gestures",
|
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||||
)
|
enabled = state.isPremium,
|
||||||
|
description = stringResource(R.string.head_gestures_details)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.head_gestures_details),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Head Orientation",
|
"Head Orientation",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
HeadVisualization()
|
HeadVisualization()
|
||||||
|
|
||||||
@@ -200,12 +215,12 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
Text(
|
Text(
|
||||||
"Velocity",
|
"Velocity",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
AccelerationPlot()
|
AccelerationPlot()
|
||||||
|
|
||||||
@@ -381,7 +396,8 @@ private fun HeadVisualization() {
|
|||||||
.aspectRatio(2f),
|
.aspectRatio(2f),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = backgroundColor
|
containerColor = backgroundColor
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -456,9 +472,9 @@ private fun HeadVisualization() {
|
|||||||
spherePath.close()
|
spherePath.close()
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.3f,
|
center.x + sinY * faceRadius * 0.3f,
|
||||||
center.y - sinP * faceRadius * 0.3f,
|
center.y - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 1.4f,
|
faceRadius * 1.4f,
|
||||||
@@ -470,14 +486,14 @@ private fun HeadVisualization() {
|
|||||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||||
),
|
),
|
||||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), paint)
|
drawPath(spherePath.asAndroidPath(), paint)
|
||||||
|
|
||||||
val highlightPaint = android.graphics.Paint().apply {
|
val highlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 0.9f,
|
faceRadius * 0.9f,
|
||||||
@@ -487,15 +503,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 0.3f, 1f),
|
floatArrayOf(0f, 0.3f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 30 else 60
|
alpha = if (darkTheme) 30 else 60
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||||
|
|
||||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
val secondaryHighlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 0.7f,
|
faceRadius * 0.7f,
|
||||||
@@ -504,15 +520,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 1f),
|
floatArrayOf(0f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 15 else 30
|
alpha = if (darkTheme) 15 else 30
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||||
|
|
||||||
val shadowPaint = android.graphics.Paint().apply {
|
val shadowPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.5f,
|
center.x + sinY * faceRadius * 0.5f,
|
||||||
center.y - sinP * faceRadius * 0.5f,
|
center.y - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 1.1f,
|
faceRadius * 1.1f,
|
||||||
@@ -521,7 +537,7 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.BLACK
|
android.graphics.Color.BLACK
|
||||||
),
|
),
|
||||||
floatArrayOf(0.7f, 1f),
|
floatArrayOf(0.7f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 40 else 20
|
alpha = if (darkTheme) 40 else 20
|
||||||
}
|
}
|
||||||
@@ -581,13 +597,13 @@ private fun HeadVisualization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
typeface = android.graphics.Typeface.create(
|
typeface = Typeface.create(
|
||||||
"SF Pro",
|
"SF Pro",
|
||||||
android.graphics.Typeface.NORMAL
|
Typeface.NORMAL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +648,8 @@ private fun AccelerationPlot() {
|
|||||||
.height(300.dp),
|
.height(300.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -700,10 +717,10 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||||
@@ -716,20 +733,20 @@ private fun AccelerationPlot() {
|
|||||||
|
|
||||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
@@ -737,11 +754,3 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun HeadTrackingScreenPreview() {
|
|
||||||
HeadTrackingScreen(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
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
|
||||||
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
@@ -50,15 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||||
isSystemInDarkTheme()
|
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")
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.adjustments)
|
title = stringResource(R.string.adjustments)
|
||||||
@@ -96,13 +98,10 @@ 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 eq = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = 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) }
|
||||||
@@ -111,8 +110,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = eq.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = eq.value,
|
rightEQ = rightEQ.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,
|
||||||
@@ -128,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
val hearingAidATTListener = remember {
|
||||||
object : (ByteArray) -> Unit {
|
object : (ByteArray) -> Unit {
|
||||||
override fun invoke(value: ByteArray) {
|
override fun invoke(value: ByteArray) {
|
||||||
@@ -157,7 +137,8 @@ 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
|
||||||
eq.value = parsed.leftEQ.copyOf()
|
leftEQ.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 {
|
||||||
@@ -167,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
@@ -192,8 +160,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
}
|
}
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = eq.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = eq.value,
|
rightEQ = rightEQ.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,
|
||||||
@@ -216,26 +184,6 @@ 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
|
||||||
@@ -261,7 +209,8 @@ 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
|
||||||
eq.value = parsedSettings.leftEQ.copyOf()
|
leftEQ.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 {
|
||||||
@@ -277,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -289,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||||
description = stringResource(R.string.swipe_amplification_description)
|
description = stringResource(R.string.swipe_amplification_description)
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -309,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -321,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -332,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description)
|
||||||
)
|
)
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -61,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "AccessibilitySettings"
|
private const val TAG = "AccessibilitySettings"
|
||||||
@@ -78,23 +78,22 @@ private const val TAG = "AccessibilitySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidScreen(navController: NavController) {
|
fun HearingAidScreen(viewModel: AirPodsViewModel, 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 snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val initialLoad = remember { mutableStateOf(true) }
|
val initialLoad = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
val hearingAidEnabled = remember {
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.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
|
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
|
||||||
@@ -102,7 +101,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_aid),
|
title = stringResource(R.string.hearing_aid),
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
@@ -113,43 +112,18 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
hazeStateS.value = hazeState
|
hazeStateS.value = hazeState
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
// val 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) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hearingAidEnabled.value) {
|
LaunchedEffect(hearingAidEnabled.value) {
|
||||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||||
hearingAidEnabled.value = false
|
hearingAidEnabled.value = false
|
||||||
}
|
}
|
||||||
initialLoad.value = false
|
initialLoad.value = false
|
||||||
@@ -186,7 +160,8 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.hearing_aid),
|
label = stringResource(R.string.hearing_aid),
|
||||||
checkedState = hearingAidEnabled,
|
checked = hearingAidEnabled.value,
|
||||||
|
onCheckedChange = { hearingAidEnabled.value = it },
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -198,7 +173,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "hearing_aid_adjustments",
|
to = "hearing_aid_adjustments",
|
||||||
name = stringResource(R.string.adjustments),
|
name = stringResource(R.string.adjustments),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +192,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "update_hearing_test",
|
to = "update_hearing_test",
|
||||||
name = stringResource(R.string.update_hearing_test),
|
name = stringResource(R.string.update_hearing_test),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = true
|
independent = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,9 +233,9 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
// independent = false
|
// independent = false
|
||||||
// )
|
// )
|
||||||
// }
|
// }
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
showDialog = showDialog,
|
showDialog = showDialog,
|
||||||
title = "Enable Hearing Aid",
|
title = "Enable Hearing Aid",
|
||||||
@@ -269,25 +244,32 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
dismissText = "Cancel",
|
dismissText = "Cancel",
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
|
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
|
||||||
if (!enrolled) {
|
if (!enrolled) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
} else {
|
} else {
|
||||||
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
}
|
}
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
|
||||||
hearingAidEnabled.value = true
|
hearingAidEnabled.value = true
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
if (state.hearingAidData.isEmpty()) {
|
||||||
val parsed = parseTransparencySettingsResponse(data)
|
Log.w(TAG, "read failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(attManager, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onDismiss = {
|
||||||
|
hearingAidEnabled.value = false
|
||||||
|
showDialog.value = false
|
||||||
|
},
|
||||||
hazeState = hazeStateS.value,
|
hazeState = hazeStateS.value,
|
||||||
// backdrop = backdrop
|
// backdrop = backdrop
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.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 me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.hearing_protection),
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.vendorIdHook) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.environmental_noise),
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 1,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
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.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||||
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.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
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
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = LocalContext.current
|
||||||
val libraries by produceLibraries {
|
val libraries by produceLibraries {
|
||||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
@@ -90,4 +79,4 @@ fun OpenSourceLicensesScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,58 +1,46 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
||||||
@@ -66,54 +54,34 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.SelectItem
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDivider() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 72.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDividerNoIcon() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 20.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(viewModel: AirPodsViewModel, name: String, 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 modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val state by viewModel.uiState.collectAsState()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
|
||||||
if (modesByte != null) {
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||||
@@ -124,9 +92,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = name
|
title = name
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
@@ -148,11 +115,36 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||||
}
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
StyledSelectList(items = actionItems)
|
StyledSelectList(items = actionItems)
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -161,6 +153,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
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)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -176,17 +169,18 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
val allowOff = offListeningModeValue == 1.toByte()
|
val allowOff = offListeningModeValue == 1.toByte()
|
||||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||||
|
|
||||||
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
?.get(0)?.toInt()
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||||
var currentByte by remember { mutableStateOf(initialByte) }
|
|
||||||
|
var currentByte by remember { mutableIntStateOf(initialByte) }
|
||||||
|
|
||||||
val listeningModeItems = mutableListOf<SelectItem>()
|
val listeningModeItems = mutableListOf<SelectItem>()
|
||||||
if (allowOff) {
|
if (allowOff) {
|
||||||
listeningModeItems.add(
|
listeningModeItems.add(
|
||||||
SelectItem(
|
SelectItem(
|
||||||
name = stringResource(R.string.off),
|
name = stringResource(R.string.off),
|
||||||
description = "Turns off noise management",
|
description = stringResource(R.string.listening_mode_off_description),
|
||||||
iconRes = R.drawable.noise_cancellation,
|
iconRes = R.drawable.noise_cancellation,
|
||||||
selected = (currentByte and 0x01) != 0,
|
selected = (currentByte and 0x01) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -197,8 +191,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -212,7 +206,7 @@ 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 = "Lets in external sounds",
|
description = stringResource(R.string.listening_mode_transparency_description),
|
||||||
iconRes = R.drawable.transparency,
|
iconRes = R.drawable.transparency,
|
||||||
selected = (currentByte and 0x04) != 0,
|
selected = (currentByte and 0x04) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -223,8 +217,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -235,7 +229,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
),
|
),
|
||||||
SelectItem(
|
SelectItem(
|
||||||
name = stringResource(R.string.adaptive),
|
name = stringResource(R.string.adaptive),
|
||||||
description = "Dynamically adjust external noise",
|
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||||
iconRes = R.drawable.adaptive,
|
iconRes = R.drawable.adaptive,
|
||||||
selected = (currentByte and 0x08) != 0,
|
selected = (currentByte and 0x08) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -246,8 +240,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -258,7 +252,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
),
|
),
|
||||||
SelectItem(
|
SelectItem(
|
||||||
name = stringResource(R.string.noise_cancellation),
|
name = stringResource(R.string.noise_cancellation),
|
||||||
description = "Blocks out external sounds",
|
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||||
iconRes = R.drawable.noise_cancellation,
|
iconRes = R.drawable.noise_cancellation,
|
||||||
selected = (currentByte and 0x02) != 0,
|
selected = (currentByte and 0x02) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -269,8 +263,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -296,9 +290,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun countEnabledModes(byteValue: Int): Int {
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PurchaseScreen(
|
||||||
|
viewModel: PurchaseViewModel = viewModel(),
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.unlock_advanced_features)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||||
|
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
LaunchedEffect(state.isPremium) {
|
||||||
|
if (state.isPremium) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.free_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ear_detection),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ear_detection_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.battery),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.battery_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.noise_control),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.noise_control_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
|
||||||
|
R.string.requires_xposed
|
||||||
|
) + ")",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.digital_assistant_on_long_press),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.head_gestures),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.head_gestures_details),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_device_settings),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_device_settings_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.automatic_connection),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.automatic_connection_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_the_development),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_development_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.feature_availability_disclaimer),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.purchase(context)
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
|
||||||
|
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.buy_price, state.price),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.restorePurchases()
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
isInteractive = false
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.restore_purchases),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -53,26 +53,20 @@ 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
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RenameScreen(navController: NavController) {
|
fun RenameScreen(viewModel: AirPodsViewModel) {
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
|
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
@@ -83,15 +77,12 @@ fun RenameScreen(navController: NavController) {
|
|||||||
name.value = name.value.copy(selection = TextRange(name.value.text.length))
|
name.value = name.value.copy(selection = TextRange(name.value.text.length))
|
||||||
}
|
}
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.name),
|
title = stringResource(R.string.name),
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
@@ -115,7 +106,7 @@ fun RenameScreen(navController: NavController) {
|
|||||||
onValueChange = {
|
onValueChange = {
|
||||||
name.value = it
|
name.value = it
|
||||||
sharedPreferences.edit {putString("name", it.text)}
|
sharedPreferences.edit {putString("name", it.text)}
|
||||||
ServiceManager.getService()?.setName(it.text)
|
viewModel.setName(it.text)
|
||||||
},
|
},
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
@@ -159,9 +150,3 @@ fun RenameScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun RenameScreenPreview() {
|
|
||||||
RenameScreen(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -41,8 +42,9 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
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
|
||||||
@@ -58,23 +60,20 @@ 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.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
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.data.TransparencySettings
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.TransparencySettings
|
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -84,14 +83,10 @@ private const val TAG = "TransparencySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TransparencySettingsScreen(navController: NavController) {
|
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||||
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 attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val isSdpOffsetAvailable =
|
|
||||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
|
||||||
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||||
@@ -99,9 +94,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_transparency_mode)
|
title = stringResource(R.string.customize_transparency_mode)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -111,7 +109,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = remember { mutableStateOf(false) }
|
||||||
@@ -148,23 +146,6 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparencyListener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseTransparencySettingsResponse(value)
|
|
||||||
enabled.value = parsed.enabled
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue =
|
|
||||||
parsed.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
|
||||||
eq.value = parsed.leftEQ.copyOf()
|
|
||||||
Log.d(TAG, "Updated transparency settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
enabled.value,
|
enabled.value,
|
||||||
amplificationSliderValue.floatValue,
|
amplificationSliderValue.floatValue,
|
||||||
@@ -205,35 +186,22 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
balance = balanceSliderValue.floatValue
|
balance = balanceSliderValue.floatValue
|
||||||
)
|
)
|
||||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||||
sendTransparencySettings(attManager, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
Log.d(TAG, "Connecting to ATT...")
|
||||||
try {
|
try {
|
||||||
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
|
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
||||||
try {
|
try {
|
||||||
if (aacpManager != null) {
|
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
val aacpEQ = state.eqData
|
||||||
val aacpEQ = aacpManager.eqData
|
if (aacpEQ.isNotEmpty()) {
|
||||||
if (aacpEQ.isNotEmpty()) {
|
eq.value = aacpEQ.copyOf()
|
||||||
eq.value = aacpEQ.copyOf()
|
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No AACPManager available")
|
Log.d(TAG, "AACPManager EQ data empty")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||||
@@ -243,7 +211,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
for (attempt in 1..3) {
|
for (attempt in 1..3) {
|
||||||
initialReadAttempts.intValue = attempt
|
initialReadAttempts.intValue = attempt
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
val data = state.transparencyData
|
||||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
parsedSettings = parseTransparencySettingsResponse(data = data)
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -276,19 +244,19 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show transparency mode section if SDP offset is available
|
if (state.vendorIdHook) {
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.transparency_mode),
|
label = stringResource(R.string.transparency_mode),
|
||||||
checkedState = enabled,
|
checked = enabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.customize_transparency_mode_description)
|
description = stringResource(R.string.customize_transparency_mode_description),
|
||||||
|
onCheckedChange = { enabled.value = it }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -300,7 +268,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -313,7 +281,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -325,7 +293,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -336,14 +304,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description),
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Only show transparency mode EQ section if SDP offset is available
|
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.equalizer),
|
text = stringResource(R.string.equalizer),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -443,6 +409,8 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -94,7 +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.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
import me.kavishdevar.librepods.utils.LogCollector
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -216,7 +216,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.troubleshooting)
|
title = stringResource(R.string.troubleshooting)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -225,7 +225,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.saved_logs),
|
text = stringResource(R.string.saved_logs),
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -32,18 +32,19 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
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
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
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
|
||||||
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.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
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
|
||||||
@@ -52,32 +53,25 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.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
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
fun UpdateHearingTestScreen() {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val attManager = ServiceManager.getService()?.attManager
|
||||||
if (attManager == null) {
|
if (attManager == null) {
|
||||||
@@ -91,11 +85,10 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -105,16 +98,25 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_test_value_instruction),
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
fontSize = 16.sp,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
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 conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||||
@@ -128,40 +130,21 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = 0.5f,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = 0.5f,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = 0.5f,
|
leftTone = tone.floatValue,
|
||||||
rightTone = 0.5f,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = 0.0f,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = 0.0f,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = 0.5f,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = 0.5f
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
val hearingAidATTListener = remember {
|
||||||
object : (ByteArray) -> Unit {
|
object : (ByteArray) -> Unit {
|
||||||
override fun invoke(value: ByteArray) {
|
override fun invoke(value: ByteArray) {
|
||||||
@@ -170,6 +153,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
|
tone.floatValue = parsed.leftTone
|
||||||
|
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||||
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
@@ -178,44 +166,52 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(
|
||||||
|
leftEQ.value,
|
||||||
|
rightEQ.value,
|
||||||
|
conversationBoostEnabled.value,
|
||||||
|
initialLoadComplete.value,
|
||||||
|
initialReadSucceeded.value,
|
||||||
|
leftAmplification.floatValue,
|
||||||
|
rightAmplification.floatValue,
|
||||||
|
tone.floatValue,
|
||||||
|
ambientNoiseReduction.floatValue,
|
||||||
|
ownVoiceAmplification.floatValue
|
||||||
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
if (!initialReadSucceeded.value) {
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Initial device read not successful yet - skipping send until read succeeds"
|
||||||
|
)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = 0.5f,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = 0.5f,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = 0.5f,
|
leftTone = tone.floatValue,
|
||||||
rightTone = 0.5f,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = 0.0f,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = 0.0f,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = 0.5f,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = 0.5f
|
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, debounceJob)
|
||||||
@@ -227,24 +223,6 @@ fun UpdateHearingTestScreen(@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()) {
|
|
||||||
leftEQ.value = aacpEQ.copyOf()
|
|
||||||
rightEQ.value = aacpEQ.copyOf()
|
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "No AACPManager available")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsedSettings: HearingAidSettings? = null
|
var parsedSettings: HearingAidSettings? = null
|
||||||
for (attempt in 1..3) {
|
for (attempt in 1..3) {
|
||||||
initialReadAttempts.intValue = attempt
|
initialReadAttempts.intValue = attempt
|
||||||
@@ -268,9 +246,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
|
tone.floatValue = parsedSettings.leftTone
|
||||||
|
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||||
|
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||||
|
leftAmplification.floatValue = parsedSettings.leftAmplification
|
||||||
|
rightAmplification.floatValue = parsedSettings.rightAmplification
|
||||||
initialReadSucceeded.value = true
|
initialReadSucceeded.value = true
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -279,7 +265,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
val frequencies =
|
||||||
|
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -288,17 +275,23 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
Spacer(modifier = Modifier.width(60.dp))
|
Spacer(modifier = Modifier.width(60.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.left),
|
text = stringResource(R.string.left),
|
||||||
fontSize = 18.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
style = TextStyle(
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.right),
|
text = stringResource(R.string.right),
|
||||||
fontSize = 18.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
style = TextStyle(
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +306,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
.width(60.dp)
|
.width(60.dp)
|
||||||
.align(Alignment.CenterVertically),
|
.align(Alignment.CenterVertically),
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
fontSize = 16.sp,
|
style = TextStyle(
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = leftEQ.value[index].toString(),
|
value = leftEQ.value[index].toString(),
|
||||||
@@ -324,10 +320,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
val newArray = leftEQ.value.copyOf()
|
val newArray = leftEQ.value.copyOf()
|
||||||
newArray[index] = parsed
|
newArray[index] = parsed
|
||||||
leftEQ.value = newArray
|
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))) },
|
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
@@ -342,10 +339,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
val newArray = rightEQ.value.copyOf()
|
val newArray = rightEQ.value.copyOf()
|
||||||
newArray[index] = parsed
|
newArray[index] = parsed
|
||||||
rightEQ.value = newArray
|
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))) },
|
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
fontSize = 14.sp
|
fontSize = 14.sp
|
||||||
@@ -354,6 +352,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
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.presentation.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -45,36 +45,23 @@ 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.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.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VersionScreen(navController: NavController) {
|
fun VersionScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
title = stringResource(R.string.version)
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -93,7 +80,8 @@ fun VersionScreen(navController: NavController) {
|
|||||||
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))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +108,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version1 ?: "N/A",
|
text = state.version1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -149,7 +137,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version2 ?: "N/A",
|
text = state.version2,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -178,7 +166,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version3 ?: "N/A",
|
text = state.version3,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -189,4 +177,4 @@ fun VersionScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU 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.presentation.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
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.presentation.theme
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -61,4 +61,4 @@ fun LibrePodsTheme(
|
|||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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 Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
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.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -49,4 +49,4 @@ val Typography = Typography(
|
|||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
*/
|
*/
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsModels
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
|
import me.kavishdevar.librepods.data.Battery
|
||||||
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
|
||||||
|
@Suppress("ArrayInDataClass")
|
||||||
|
data class AirPodsUiState(
|
||||||
|
val deviceName: String,
|
||||||
|
|
||||||
|
val isLocallyConnected: Boolean = false,
|
||||||
|
|
||||||
|
val instance: AirPodsInstance? = null,
|
||||||
|
val capabilities: Set<Capability> = emptySet(),
|
||||||
|
|
||||||
|
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
|
||||||
|
val offListeningMode: Boolean = true,
|
||||||
|
|
||||||
|
val battery: List<Battery> = emptyList(),
|
||||||
|
val ancMode: Int = 3,
|
||||||
|
|
||||||
|
val modelName: String = "",
|
||||||
|
val actualModel: String = "",
|
||||||
|
val serialNumbers: List<String> = emptyList(),
|
||||||
|
val version1: String = "",
|
||||||
|
val version2: String = "",
|
||||||
|
val version3: String = "",
|
||||||
|
|
||||||
|
val headTrackingActive: Boolean = false,
|
||||||
|
val headGesturesEnabled: Boolean = true,
|
||||||
|
|
||||||
|
val eqData: FloatArray = floatArrayOf(),
|
||||||
|
|
||||||
|
val automaticEarDetectionEnabled: Boolean = true,
|
||||||
|
val automaticConnectionEnabled: Boolean = true,
|
||||||
|
|
||||||
|
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
|
||||||
|
val loudSoundReductionEnabled: Boolean = false,
|
||||||
|
val transparencyData: ByteArray = byteArrayOf(),
|
||||||
|
val hearingAidData: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val vendorIdHook: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsViewModel(
|
||||||
|
private val service: AirPodsService,
|
||||||
|
private val sharedPreferences: SharedPreferences,
|
||||||
|
private val controlRepo: ControlCommandRepository,
|
||||||
|
private val appContext: Context
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
AirPodsUiState(
|
||||||
|
deviceName = sharedPreferences.getString(
|
||||||
|
"name",
|
||||||
|
"AirPods Pro"
|
||||||
|
) ?: "AirPods Pro"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val uiState: StateFlow<AirPodsUiState> = _uiState
|
||||||
|
|
||||||
|
private var isDemoMode = false
|
||||||
|
val demoActivated = MutableSharedFlow<Unit>()
|
||||||
|
|
||||||
|
private val listeners =
|
||||||
|
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
|
||||||
|
|
||||||
|
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
|
|
||||||
|
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||||
|
|
||||||
|
private val _cameraAction = MutableStateFlow(
|
||||||
|
sharedPreferences.getString("camera_action", null)
|
||||||
|
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
|
||||||
|
|
||||||
|
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
|
||||||
|
|
||||||
|
fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
if (action == null) remove("camera_action")
|
||||||
|
else putString("camera_action", action.name)
|
||||||
|
}
|
||||||
|
_cameraAction.value = action
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeBroadcasts()
|
||||||
|
loadName()
|
||||||
|
loadInstance()
|
||||||
|
loadSharedPreferences()
|
||||||
|
setupControlObservers()
|
||||||
|
observeBilling()
|
||||||
|
loadControlList()
|
||||||
|
observeATT()
|
||||||
|
if (isDemoMode) activateDemoMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
listeners.forEach { (id, listener) ->
|
||||||
|
controlRepo.remove(id, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
appContext.unregisterReceiver(broadcastReceiver)
|
||||||
|
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadName() {
|
||||||
|
val name = sharedPreferences.getString("name", "AirPods Pro")!!
|
||||||
|
_uiState.update { it.copy(deviceName = name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
if (isDemoMode) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
// if (!billingFirstCollectDone) {
|
||||||
|
// billingFirstCollectDone = true
|
||||||
|
// return@collect
|
||||||
|
// }
|
||||||
|
if (!premium) {
|
||||||
|
setControlCommandBoolean(
|
||||||
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
setHeadGesturesEnabled(false)
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBroadcasts() {
|
||||||
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
if (!isDemoMode) when (action) {
|
||||||
|
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLocallyConnected = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLocallyConnected = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.BATTERY_DATA -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(battery = service.getBattery())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.EQ_DATA -> {
|
||||||
|
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(eqData = data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
|
||||||
|
loadInstance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||||
|
addAction(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
addAction(AirPodsNotifications.EQ_DATA)
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
appContext.registerReceiver(
|
||||||
|
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandValue(
|
||||||
|
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||||
|
) {
|
||||||
|
if (!isDemoMode) controlRepo.setValue(identifier, value)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
controlStates = it.controlStates + (identifier to value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandBoolean(
|
||||||
|
identifier: ControlCommandIdentifiers, enabled: Boolean
|
||||||
|
) {
|
||||||
|
setControlCommandValue(
|
||||||
|
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandInt(
|
||||||
|
identifier: ControlCommandIdentifiers, value: Int
|
||||||
|
) {
|
||||||
|
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandByte(
|
||||||
|
identifier: ControlCommandIdentifiers, value: Byte
|
||||||
|
) {
|
||||||
|
setControlCommandValue(identifier, byteArrayOf(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeControl(identifier: ControlCommandIdentifiers) {
|
||||||
|
val listener = controlRepo.observe(identifier) { value ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
val current = state.controlStates[identifier]
|
||||||
|
if (current?.contentEquals(value) == true) return@update state
|
||||||
|
|
||||||
|
state.copy(
|
||||||
|
controlStates = state.controlStates + (identifier to value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners[identifier] = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'm lazy, sorry.
|
||||||
|
fun setupControlObservers() {
|
||||||
|
val identifiersList = listOf(
|
||||||
|
ControlCommandIdentifiers.MIC_MODE,
|
||||||
|
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
|
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
||||||
|
ControlCommandIdentifiers.LISTENING_MODE,
|
||||||
|
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
|
||||||
|
ControlCommandIdentifiers.CHIME_VOLUME,
|
||||||
|
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||||
|
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
||||||
|
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||||
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
ControlCommandIdentifiers.HEARING_AID,
|
||||||
|
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||||
|
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
||||||
|
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
|
||||||
|
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||||
|
ControlCommandIdentifiers.STEM_CONFIG,
|
||||||
|
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
|
||||||
|
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.OWNS_CONNECTION,
|
||||||
|
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
|
||||||
|
)
|
||||||
|
for (identifier in identifiersList) {
|
||||||
|
observeControl(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshInitialData() {
|
||||||
|
if (isDemoMode) return
|
||||||
|
service.let { service ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLocallyConnected = service.isConnected(), battery = service.getBattery()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSharedPreferences() {
|
||||||
|
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
|
||||||
|
val automaticEarDetectionEnabled =
|
||||||
|
sharedPreferences.getBoolean("automatic_ear_detection", true)
|
||||||
|
val automaticConnectionEnabled =
|
||||||
|
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
|
||||||
|
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
|
||||||
|
val leftAction = StemAction.valueOf(
|
||||||
|
sharedPreferences.getString(
|
||||||
|
"left_long_press_action",
|
||||||
|
"CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
)
|
||||||
|
val rightAction = StemAction.valueOf(
|
||||||
|
sharedPreferences.getString(
|
||||||
|
"right_long_press_action",
|
||||||
|
"CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
)
|
||||||
|
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
offListeningMode = offListeningModeEnabled,
|
||||||
|
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
|
||||||
|
automaticConnectionEnabled = automaticConnectionEnabled,
|
||||||
|
headGesturesEnabled = headGesturesEnabled,
|
||||||
|
leftAction = leftAction,
|
||||||
|
rightAction = rightAction,
|
||||||
|
vendorIdHook = vendorIdHook
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOffListeningMode(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(offListeningMode = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(headGesturesEnabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadControlList() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
controlStates = controlRepo.getMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInstance() {
|
||||||
|
val instance = service.airpodsInstance ?: AirPodsInstance(
|
||||||
|
name = "AirPods",
|
||||||
|
model = AirPodsModels.getModelByModelNumber("A3049")!!,
|
||||||
|
actualModelNumber = "A3049",
|
||||||
|
serialNumber = null,
|
||||||
|
leftSerialNumber = null,
|
||||||
|
rightSerialNumber = null,
|
||||||
|
version1 = null,
|
||||||
|
version2 = null,
|
||||||
|
version3 = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
capabilities = instance.model.capabilities,
|
||||||
|
instance = instance,
|
||||||
|
modelName = instance.model.displayName,
|
||||||
|
actualModel = instance.actualModelNumber,
|
||||||
|
serialNumbers = listOf(
|
||||||
|
instance.serialNumber ?: "",
|
||||||
|
instance.leftSerialNumber ?: "",
|
||||||
|
instance.rightSerialNumber ?: ""
|
||||||
|
),
|
||||||
|
version1 = instance.version1 ?: "",
|
||||||
|
version2 = instance.version2 ?: "",
|
||||||
|
version3 = instance.version3 ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnectFromSavedMac() {
|
||||||
|
service.reconnectFromSavedMac()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setName(name: String) {
|
||||||
|
service.setName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHeadTracking() {
|
||||||
|
service.startHeadTracking()
|
||||||
|
_uiState.update { it.copy(headTrackingActive = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopHeadTracking() {
|
||||||
|
service.stopHeadTracking()
|
||||||
|
_uiState.update { it.copy(headTrackingActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
|
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
||||||
|
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
service.attManager?.connect()
|
||||||
|
while (service.attManager?.socket?.isConnected != true) {
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
service.attManager?.write(handle, value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshATT() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val loudSoundReduction =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
||||||
|
val transparencyData =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
||||||
|
val hearingAid =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
||||||
|
transparencyData = transparencyData,
|
||||||
|
hearingAidData = hearingAid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeATT() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
service.attManager?.connect()
|
||||||
|
while (service.attManager?.socket?.isConnected != true) {
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
refreshATT()
|
||||||
|
delay(15000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
automaticEarDetectionEnabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutomaticConnectionEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
automaticConnectionEnabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activateDemoMode() {
|
||||||
|
isDemoMode = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
demoActivated.emit(Unit)
|
||||||
|
}
|
||||||
|
val fakeInstance = AirPodsInstance(
|
||||||
|
name = "AirPods Pro (Demo)",
|
||||||
|
model = AirPodsModels.getModelByModelNumber("A3049")!!,
|
||||||
|
actualModelNumber = "A3049",
|
||||||
|
serialNumber = "DEMO123",
|
||||||
|
leftSerialNumber = "L-DEMO",
|
||||||
|
rightSerialNumber = "R-DEMO",
|
||||||
|
version1 = "1.0",
|
||||||
|
version2 = "1.0",
|
||||||
|
version3 = "1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLocallyConnected = true,
|
||||||
|
instance = fakeInstance,
|
||||||
|
capabilities = fakeInstance.model.capabilities,
|
||||||
|
|
||||||
|
battery = listOf(
|
||||||
|
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||||
|
Battery(BatteryComponent.RIGHT, 25, BatteryStatus.NOT_CHARGING),
|
||||||
|
Battery(BatteryComponent.CASE, 85, BatteryStatus.CHARGING),
|
||||||
|
),
|
||||||
|
|
||||||
|
modelName = fakeInstance.model.displayName,
|
||||||
|
actualModel = fakeInstance.actualModelNumber,
|
||||||
|
serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
|
||||||
|
version3 = "Demo Firmware",
|
||||||
|
// isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
|
||||||
|
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
service.disconnectAirPods()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
|
import me.kavishdevar.librepods.utils.NativeBridge
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class AppSettingsUiState(
|
||||||
|
val showPhoneBatteryInWidget: Boolean = false,
|
||||||
|
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
|
||||||
|
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
|
||||||
|
val disconnectWhenNotWearing: Boolean = false,
|
||||||
|
val takeoverWhenDisconnected: Boolean = false,
|
||||||
|
val takeoverWhenIdle: Boolean = false,
|
||||||
|
val takeoverWhenMusic: Boolean = false,
|
||||||
|
val takeoverWhenCall: Boolean = false,
|
||||||
|
val takeoverWhenRingingCall: Boolean = false,
|
||||||
|
val takeoverWhenMediaStart: Boolean = false,
|
||||||
|
val useAlternateHeadTrackingPackets: Boolean = true,
|
||||||
|
val conversationalAwarenessVolume: Float = 43f,
|
||||||
|
val showCameraDialog: Boolean = false,
|
||||||
|
val cameraPackageValue: String = "",
|
||||||
|
val cameraPackageError: String? = null,
|
||||||
|
val vendorIdHook: Boolean = false,
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val connectionSuccessful: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
|
|
||||||
|
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
|
||||||
|
if (key == "connection_successful") {
|
||||||
|
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSettings()
|
||||||
|
observeBilling()
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettings() {
|
||||||
|
_uiState.update { currentState ->
|
||||||
|
currentState.copy(
|
||||||
|
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||||
|
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
|
||||||
|
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
|
||||||
|
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
|
||||||
|
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
|
||||||
|
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
|
||||||
|
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
|
||||||
|
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
|
||||||
|
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
|
||||||
|
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
|
||||||
|
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
|
||||||
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
|
||||||
|
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
|
||||||
|
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
|
||||||
|
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
|
||||||
|
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
|
||||||
|
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
|
||||||
|
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisconnectWhenNotWearing(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
|
||||||
|
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenDisconnected(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenIdle(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenMusic(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenCall(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenCall = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenRingingCall(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenMediaStart(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
|
||||||
|
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationalAwarenessVolume(volume: Float) {
|
||||||
|
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
|
||||||
|
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowCameraDialog(show: Boolean) {
|
||||||
|
_uiState.update { it.copy(showCameraDialog = show) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraPackageValue(value: String) {
|
||||||
|
_uiState.update { it.copy(cameraPackageValue = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraPackageError(error: String?) {
|
||||||
|
_uiState.update { it.copy(cameraPackageError = error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCameraPackage() {
|
||||||
|
if (_uiState.value.cameraPackageValue.isBlank()) {
|
||||||
|
sharedPreferences.edit { remove("custom_camera_package") }
|
||||||
|
} else {
|
||||||
|
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
|
||||||
|
}
|
||||||
|
setShowCameraDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVendorIdHook(enabled: Boolean) {
|
||||||
|
NativeBridge.setSdpHook(enabled)
|
||||||
|
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
|
||||||
|
_uiState.update { it.copy(vendorIdHook = enabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
|
||||||
|
data class PurchaseUiState(
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val price: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
class PurchaseViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val _uiState = MutableStateFlow(PurchaseUiState())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeBilling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.price.collect { price ->
|
||||||
|
_uiState.update { it.copy(price = price) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purchase(context: Context) {
|
||||||
|
BillingManager.provider.purchase(context as Activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restorePurchases() {
|
||||||
|
BillingManager.provider.restorePurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, 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.presentation.widgets
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.Context
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
class BatteryWidget : AppWidgetProvider() {
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
ServiceManager.getService()?.updateBattery()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.presentation.widgets
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@@ -29,7 +29,7 @@ import android.util.Log
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
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.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s 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
|
||||||
* 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 Affero General Public License for more details.
|
GNU General Public License for more details.
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
You should have received a copy of the GNU 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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,840 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.layout.positionInParent
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledDropdown
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var phoneMediaDebounceJob: Job? = null
|
|
||||||
private var toneVolumeDebounceJob: Job? = null
|
|
||||||
private const val TAG = "AccessibilitySettings"
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AccessibilitySettingsScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val isSdpOffsetAvailable =
|
|
||||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
|
||||||
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
|
|
||||||
|
|
||||||
val hearingAidEnabled = remember { mutableStateOf(
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
|
|
||||||
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
|
|
||||||
) }
|
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.accessibility)
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
|
||||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
|
||||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val pressSpeedOptions = mapOf(
|
|
||||||
0.toByte() to "Default",
|
|
||||||
1.toByte() to "Slower",
|
|
||||||
2.toByte() to "Slowest"
|
|
||||||
)
|
|
||||||
val selectedPressSpeedValue =
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
|
||||||
?.get(0)
|
|
||||||
var selectedPressSpeed by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
|
|
||||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
|
||||||
selectedPressSpeedListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
|
||||||
selectedPressSpeedListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val pressAndHoldDurationOptions = mapOf(
|
|
||||||
0.toByte() to "Default",
|
|
||||||
1.toByte() to "Slower",
|
|
||||||
2.toByte() to "Slowest"
|
|
||||||
)
|
|
||||||
val selectedPressAndHoldDurationValue =
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
|
||||||
?.get(0)
|
|
||||||
var selectedPressAndHoldDuration by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
|
||||||
?: pressAndHoldDurationOptions[0]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
|
|
||||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
selectedPressAndHoldDuration =
|
|
||||||
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
|
||||||
selectedPressAndHoldDurationListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
|
||||||
selectedPressAndHoldDurationListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val volumeSwipeSpeedOptions = mapOf(
|
|
||||||
1.toByte() to "Default",
|
|
||||||
2.toByte() to "Longer",
|
|
||||||
3.toByte() to "Longest"
|
|
||||||
)
|
|
||||||
val selectedVolumeSwipeSpeedValue =
|
|
||||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
|
||||||
?.get(0)
|
|
||||||
var selectedVolumeSwipeSpeed by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
|
||||||
?: volumeSwipeSpeedOptions[1]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
|
|
||||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
selectedVolumeSwipeSpeed =
|
|
||||||
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
|
||||||
selectedVolumeSwipeSpeedListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
|
||||||
selectedVolumeSwipeSpeedListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
|
||||||
phoneMediaDebounceJob?.cancel()
|
|
||||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
delay(150)
|
|
||||||
val manager = ServiceManager.getService()?.aacpManager
|
|
||||||
if (manager == null) {
|
|
||||||
Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
|
||||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
|
||||||
)
|
|
||||||
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val toneVolumeValue = remember { mutableFloatStateOf(
|
|
||||||
aacpManager?.controlCommandStatusList?.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
|
|
||||||
) }
|
|
||||||
LaunchedEffect(toneVolumeValue.floatValue) {
|
|
||||||
toneVolumeDebounceJob?.cancel()
|
|
||||||
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
delay(150)
|
|
||||||
val manager = ServiceManager.getService()?.aacpManager
|
|
||||||
if (manager == null) {
|
|
||||||
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
manager.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
|
||||||
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending tone volume: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.press_speed),
|
|
||||||
description = stringResource(R.string.press_speed_description),
|
|
||||||
options = pressSpeedOptions.values.toList(),
|
|
||||||
selectedOption = selectedPressSpeed?: "Default",
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedPressSpeed = newValue
|
|
||||||
aacpManager?.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
|
||||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
|
||||||
?: 0.toByte()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
|
||||||
hazeState = hazeState,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.press_and_hold_duration),
|
|
||||||
description = stringResource(R.string.press_and_hold_duration_description),
|
|
||||||
options = pressAndHoldDurationOptions.values.toList(),
|
|
||||||
selectedOption = selectedPressAndHoldDuration?: "Default",
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedPressAndHoldDuration = newValue
|
|
||||||
aacpManager?.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
|
||||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
|
||||||
?: 0.toByte()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
|
||||||
hazeState = hazeState,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.noise_control),
|
|
||||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
|
||||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
|
||||||
independent = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "transparency_customization",
|
|
||||||
name = stringResource(R.string.customize_transparency_mode),
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.tone_volume),
|
|
||||||
description = stringResource(R.string.tone_volume_description),
|
|
||||||
mutableFloatState = toneVolumeValue,
|
|
||||||
onValueChange = {
|
|
||||||
toneVolumeValue.floatValue = it
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(75f),
|
|
||||||
startIcon = "\uDBC0\uDEA1",
|
|
||||||
endIcon = "\uDBC0\uDEA9",
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.volume_control),
|
|
||||||
description = stringResource(R.string.volume_control_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.volume_swipe_speed),
|
|
||||||
description = stringResource(R.string.volume_swipe_speed_description),
|
|
||||||
options = volumeSwipeSpeedOptions.values.toList(),
|
|
||||||
selectedOption = selectedVolumeSwipeSpeed?: "Default",
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedVolumeSwipeSpeed = newValue
|
|
||||||
aacpManager?.sendControlCommand(
|
|
||||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
|
||||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
|
||||||
?: 1.toByte()
|
|
||||||
)
|
|
||||||
},
|
|
||||||
textColor = textColor,
|
|
||||||
hazeState = hazeState,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
|
||||||
// Text(
|
|
||||||
// text = stringResource(R.string.apply_eq_to),
|
|
||||||
// style = TextStyle(
|
|
||||||
// fontSize = 14.sp,
|
|
||||||
// fontWeight = FontWeight.Bold,
|
|
||||||
// color = textColor.copy(alpha = 0.6f),
|
|
||||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
// ),
|
|
||||||
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
|
||||||
// )
|
|
||||||
// Column(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
// .padding(vertical = 0.dp)
|
|
||||||
// ) {
|
|
||||||
// val darkModeLocal = isSystemInDarkTheme()
|
|
||||||
//
|
|
||||||
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
|
||||||
// var phoneBackgroundColor by remember {
|
|
||||||
// mutableStateOf(
|
|
||||||
// if (darkModeLocal) Color(
|
|
||||||
// 0xFF1C1C1E
|
|
||||||
// ) else Color(0xFFFFFFFF)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// val phoneAnimatedBackgroundColor by animateColorAsState(
|
|
||||||
// targetValue = phoneBackgroundColor,
|
|
||||||
// animationSpec = tween(durationMillis = 500)
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// Row(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .height(48.dp)
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .background(phoneAnimatedBackgroundColor, phoneShape)
|
|
||||||
// .pointerInput(Unit) {
|
|
||||||
// detectTapGestures(
|
|
||||||
// onPress = {
|
|
||||||
// phoneBackgroundColor =
|
|
||||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
// tryAwaitRelease()
|
|
||||||
// phoneBackgroundColor =
|
|
||||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
// phoneEQEnabled.value = !phoneEQEnabled.value
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// .padding(horizontal = 16.dp),
|
|
||||||
// verticalAlignment = Alignment.CenterVertically
|
|
||||||
// ) {
|
|
||||||
// Text(
|
|
||||||
// stringResource(R.string.phone),
|
|
||||||
// fontSize = 16.sp,
|
|
||||||
// color = textColor,
|
|
||||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
// modifier = Modifier.weight(1f)
|
|
||||||
// )
|
|
||||||
// Checkbox(
|
|
||||||
// checked = phoneEQEnabled.value,
|
|
||||||
// onCheckedChange = { phoneEQEnabled.value = it },
|
|
||||||
// colors = CheckboxDefaults.colors().copy(
|
|
||||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
|
||||||
// uncheckedCheckmarkColor = Color.Transparent,
|
|
||||||
// checkedBoxColor = Color.Transparent,
|
|
||||||
// uncheckedBoxColor = Color.Transparent,
|
|
||||||
// checkedBorderColor = Color.Transparent,
|
|
||||||
// uncheckedBorderColor = Color.Transparent
|
|
||||||
// ),
|
|
||||||
// modifier = Modifier
|
|
||||||
// .height(24.dp)
|
|
||||||
// .scale(1.5f)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// HorizontalDivider(
|
|
||||||
// thickness = 1.dp,
|
|
||||||
// color = Color(0x40888888)
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
|
||||||
// var mediaBackgroundColor by remember {
|
|
||||||
// mutableStateOf(
|
|
||||||
// if (darkModeLocal) Color(
|
|
||||||
// 0xFF1C1C1E
|
|
||||||
// ) else Color(0xFFFFFFFF)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// val mediaAnimatedBackgroundColor by animateColorAsState(
|
|
||||||
// targetValue = mediaBackgroundColor,
|
|
||||||
// animationSpec = tween(durationMillis = 500)
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// Row(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .height(48.dp)
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .background(mediaAnimatedBackgroundColor, mediaShape)
|
|
||||||
// .pointerInput(Unit) {
|
|
||||||
// detectTapGestures(
|
|
||||||
// onPress = {
|
|
||||||
// mediaBackgroundColor =
|
|
||||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
// tryAwaitRelease()
|
|
||||||
// mediaBackgroundColor =
|
|
||||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
// mediaEQEnabled.value = !mediaEQEnabled.value
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// .padding(horizontal = 16.dp),
|
|
||||||
// verticalAlignment = Alignment.CenterVertically
|
|
||||||
// ) {
|
|
||||||
// Text(
|
|
||||||
// stringResource(R.string.media),
|
|
||||||
// fontSize = 16.sp,
|
|
||||||
// color = textColor,
|
|
||||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
// modifier = Modifier.weight(1f)
|
|
||||||
// )
|
|
||||||
// Checkbox(
|
|
||||||
// checked = mediaEQEnabled.value,
|
|
||||||
// onCheckedChange = { mediaEQEnabled.value = it },
|
|
||||||
// colors = CheckboxDefaults.colors().copy(
|
|
||||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
|
||||||
// uncheckedCheckmarkColor = Color.Transparent,
|
|
||||||
// checkedBoxColor = Color.Transparent,
|
|
||||||
// uncheckedBoxColor = Color.Transparent,
|
|
||||||
// checkedBorderColor = Color.Transparent,
|
|
||||||
// uncheckedBorderColor = Color.Transparent
|
|
||||||
// ),
|
|
||||||
// modifier = Modifier
|
|
||||||
// .height(24.dp)
|
|
||||||
// .scale(1.5f)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// EQ Settings. Don't seem to have an effect?
|
|
||||||
// Column(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
// .padding(12.dp),
|
|
||||||
// horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
// ) {
|
|
||||||
// for (i in 0 until 8) {
|
|
||||||
// val eqPhoneValue =
|
|
||||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
|
||||||
// Row(
|
|
||||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
// verticalAlignment = Alignment.CenterVertically,
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .height(38.dp)
|
|
||||||
// ) {
|
|
||||||
// Text(
|
|
||||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
|
||||||
// fontSize = 12.sp,
|
|
||||||
// color = textColor,
|
|
||||||
// modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
// )
|
|
||||||
|
|
||||||
// Slider(
|
|
||||||
// value = eqPhoneValue.floatValue,
|
|
||||||
// onValueChange = { newVal ->
|
|
||||||
// eqPhoneValue.floatValue = newVal
|
|
||||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
|
||||||
// newEQ[i] = eqPhoneValue.floatValue
|
|
||||||
// phoneMediaEQ.value = newEQ
|
|
||||||
// },
|
|
||||||
// valueRange = 0f..100f,
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth(0.9f)
|
|
||||||
// .height(36.dp),
|
|
||||||
// colors = SliderDefaults.colors(
|
|
||||||
// thumbColor = thumbColor,
|
|
||||||
// activeTrackColor = activeTrackColor,
|
|
||||||
// inactiveTrackColor = trackColor
|
|
||||||
// ),
|
|
||||||
// thumb = {
|
|
||||||
// Box(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .size(24.dp)
|
|
||||||
// .shadow(4.dp, CircleShape)
|
|
||||||
// .background(thumbColor, CircleShape)
|
|
||||||
// )
|
|
||||||
// },
|
|
||||||
// track = {
|
|
||||||
// Box(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .height(12.dp),
|
|
||||||
// contentAlignment = Alignment.CenterStart
|
|
||||||
// )
|
|
||||||
// {
|
|
||||||
// Box(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .height(4.dp)
|
|
||||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
|
||||||
// )
|
|
||||||
// Box(
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
|
|
||||||
// .height(4.dp)
|
|
||||||
// .background(activeTrackColor, RoundedCornerShape(4.dp))
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
|
|
||||||
// Text(
|
|
||||||
// text = stringResource(R.string.band_label, i + 1),
|
|
||||||
// fontSize = 12.sp,
|
|
||||||
// color = textColor,
|
|
||||||
// modifier = Modifier.padding(top = 4.dp)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
|
||||||
private fun DropdownMenuComponent(
|
|
||||||
label: String,
|
|
||||||
options: List<String>,
|
|
||||||
selectedOption: String,
|
|
||||||
onOptionSelected: (String) -> Unit,
|
|
||||||
textColor: Color,
|
|
||||||
hazeState: HazeState,
|
|
||||||
description: String? = null,
|
|
||||||
independent: Boolean = true
|
|
||||||
) {
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
|
||||||
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
|
||||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
|
||||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
|
||||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
|
||||||
var parentDragActive by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()){
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(
|
|
||||||
if (independent) {
|
|
||||||
if (description != null) {
|
|
||||||
Modifier.padding(top = 8.dp, bottom = 4.dp)
|
|
||||||
} else {
|
|
||||||
Modifier.padding(vertical = 8.dp)
|
|
||||||
}
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent,
|
|
||||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
|
||||||
)
|
|
||||||
then(
|
|
||||||
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
|
|
||||||
)
|
|
||||||
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
|
|
||||||
){
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 12.dp, end = 12.dp)
|
|
||||||
.height(58.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures { offset ->
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (expanded) {
|
|
||||||
expanded = false
|
|
||||||
lastDismissTime = now
|
|
||||||
} else {
|
|
||||||
if (now - lastDismissTime > 250L) {
|
|
||||||
touchOffset = offset
|
|
||||||
expanded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectDragGesturesAfterLongPress(
|
|
||||||
onDragStart = { offset ->
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
touchOffset = offset
|
|
||||||
if (!expanded && now - lastDismissTime > 250L) {
|
|
||||||
expanded = true
|
|
||||||
}
|
|
||||||
lastDismissTime = now
|
|
||||||
parentDragActive = true
|
|
||||||
parentHoveredIndex = 0
|
|
||||||
},
|
|
||||||
onDrag = { change, _ ->
|
|
||||||
val current = change.position
|
|
||||||
val touch = touchOffset ?: current
|
|
||||||
val posInPopupY = current.y - touch.y
|
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
|
||||||
parentHoveredIndex = idx
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
parentDragActive = false
|
|
||||||
parentHoveredIndex?.let { idx ->
|
|
||||||
if (idx in options.indices) {
|
|
||||||
onOptionSelected(options[idx])
|
|
||||||
expanded = false
|
|
||||||
lastDismissTime = System.currentTimeMillis()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parentHoveredIndex = null
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
parentDragActive = false
|
|
||||||
parentHoveredIndex = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
if (!independent && description != null){
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
|
||||||
boxPosition = coordinates.positionInParent()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = selectedOption,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor.copy(alpha = 0.8f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 6.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledDropdown(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = {
|
|
||||||
expanded = false
|
|
||||||
lastDismissTime = System.currentTimeMillis()
|
|
||||||
},
|
|
||||||
options = options,
|
|
||||||
selectedOption = selectedOption,
|
|
||||||
touchOffset = touchOffset,
|
|
||||||
boxPosition = boxPosition,
|
|
||||||
externalHoveredIndex = parentHoveredIndex,
|
|
||||||
externalDragActive = parentDragActive,
|
|
||||||
onOptionSelected = { option ->
|
|
||||||
onOptionSelected(option)
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
hazeState = hazeState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (independent && description != null){
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AdaptiveStrengthScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
|
||||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
|
|
||||||
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
|
|
||||||
sliderValue.floatValue = (100 - it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.customize_adaptive_audio),
|
|
||||||
mutableFloatState = sliderValue,
|
|
||||||
onValueChange = {
|
|
||||||
sliderValue.floatValue = it
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
delay(300)
|
|
||||||
service.aacpManager.sendControlCommand(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
|
||||||
(100 - it).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(0f, 50f, 100f),
|
|
||||||
startIcon = "",
|
|
||||||
endIcon = "",
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.adaptive_audio_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
/*
|
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
*
|
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published
|
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import android.content.Context.RECEIVER_EXPORTED
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import com.kyant.backdrop.drawBackdrop
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.AboutCard
|
|
||||||
import me.kavishdevar.librepods.composables.AudioSettings
|
|
||||||
import me.kavishdevar.librepods.composables.BatteryView
|
|
||||||
import me.kavishdevar.librepods.composables.CallControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
|
||||||
import me.kavishdevar.librepods.composables.ConnectionSettings
|
|
||||||
import me.kavishdevar.librepods.composables.HearingHealthSettings
|
|
||||||
import me.kavishdevar.librepods.composables.MicrophoneSettings
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|
||||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
|
||||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
|
||||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
var device by remember { mutableStateOf(dev) }
|
|
||||||
var deviceName by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
TextFieldValue(
|
|
||||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
isLocallyConnected = service.isConnectedLocally
|
|
||||||
}
|
|
||||||
|
|
||||||
val nameChangeListener = remember {
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
|
||||||
if (key == "name") {
|
|
||||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
fun handleRemoteConnection(connected: Boolean) {
|
|
||||||
isRemotelyConnected = connected
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val connectionReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
|
||||||
try {
|
|
||||||
context?.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(connectionReceiver, filter)
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(connectionReceiver)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
service.let {
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
|
||||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
|
||||||
})
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
|
|
||||||
putExtra("data", it.getANC())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val darkMode = isSystemInDarkTheme()
|
|
||||||
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = deviceName.text,
|
|
||||||
actionButtons = listOf(
|
|
||||||
{scaffoldBackdrop ->
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = { navController.navigate("app_settings") },
|
|
||||||
icon = "",
|
|
||||||
darkMode = darkMode,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
snackbarHostState = snackbarHostState
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
hazeStateS.value = hazeState
|
|
||||||
if (isLocallyConnected || isRemotelyConnected) {
|
|
||||||
val instance = service.airpodsInstance
|
|
||||||
if (instance == null) {
|
|
||||||
Text("Error: AirPods instance is null")
|
|
||||||
return@StyledScaffold
|
|
||||||
}
|
|
||||||
val capabilities = instance.model.capabilities
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
|
||||||
item(key = "battery") {
|
|
||||||
BatteryView(service = service)
|
|
||||||
}
|
|
||||||
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
|
|
||||||
item(key = "name") {
|
|
||||||
NavigationButton(
|
|
||||||
to = "rename",
|
|
||||||
name = stringResource(R.string.name),
|
|
||||||
currentState = deviceName.text,
|
|
||||||
navController = navController,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
|
|
||||||
if (actAsAppleDeviceHookEnabled) {
|
|
||||||
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "hearing_health") {
|
|
||||||
HearingHealthSettings(navController = navController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
|
||||||
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "audio") { AudioSettings(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "connection") { ConnectionSettings() }
|
|
||||||
|
|
||||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
|
||||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "sleep_detection") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.sleep_detection),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
|
||||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
|
||||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "off_listening") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.off_listening_mode),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
description = stringResource(R.string.off_listening_mode_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "about") { AboutCard(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
|
||||||
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.drawBackdrop(
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
exportedBackdrop = backdrop,
|
|
||||||
shape = { RoundedCornerShape(0.dp) },
|
|
||||||
highlight = {
|
|
||||||
Highlight.Ambient.copy(alpha = 0f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected_description),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = { navController.navigate("troubleshooting") },
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Troubleshoot Connection",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
service.reconnectFromSavedMac()
|
|
||||||
},
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reconnect_to_last_device),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConfirmationDialog(
|
|
||||||
showDialog = showDialog,
|
|
||||||
title = stringResource(R.string.support_librepods),
|
|
||||||
message = stringResource(R.string.support_dialog_description),
|
|
||||||
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
|
|
||||||
dismissText = stringResource(R.string.never_show_again),
|
|
||||||
onConfirm = {
|
|
||||||
val browserIntent = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
"https://github.com/sponsors/kavishdevar".toUri()
|
|
||||||
)
|
|
||||||
context.startActivity(browserIntent)
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
hazeState = hazeStateS.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreenPreview() {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier.height(2000.dp)
|
|
||||||
) {
|
|
||||||
LibrePodsTheme (
|
|
||||||
darkTheme = true
|
|
||||||
) {
|
|
||||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user