mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-06-14 12:26:53 +00:00
Compare commits
20 Commits
nightly-5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7341e41837 | ||
|
|
bffb5c8b3e | ||
|
|
aca4373ec4 | ||
|
|
8804197760 | ||
|
|
57d692c4ae | ||
|
|
0477674810 | ||
|
|
c1093fbe24 | ||
|
|
0f50eab788 | ||
|
|
1381022b2e | ||
|
|
af4261485a | ||
|
|
571db0ebde | ||
|
|
3c3c0edffd | ||
|
|
f86d7b9aca | ||
|
|
29a914c2ff | ||
|
|
3f2a7df749 | ||
|
|
f9367f4445 | ||
|
|
0101428108 | ||
|
|
37c1837d0b | ||
|
|
154ed17c05 | ||
|
|
362e34202a |
2
.github/workflows/ci-linux-rust.yml
vendored
2
.github/workflows/ci-linux-rust.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Linux Build & Release
|
name: Linux CI (Rust)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
287
README.md
287
README.md
@@ -1,110 +1,99 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
>[!IMPORTANT]
|
>[!IMPORTANT]
|
||||||
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||||
|
|
||||||

|
---
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="./imgs/banner.png" />
|
||||||
|
<img alt="LibrePods" src="./imgs/banner.png" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<div align="center" style="margin: 20px 0px;">
|
||||||
|
<a href="https://github.com/kavishdevar/librepods/releases/latest">
|
||||||
|
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?label=GitHub%20Downloads" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml">
|
||||||
|
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml/badge.svg" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml">
|
||||||
|
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml/badge.svg" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/kavishdevar/librepods/issues">
|
||||||
|
<img src="https://img.shields.io/github/issues/kavishdevar/librepods" />
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/HhG4ycVum4">
|
||||||
|
<img src="https://img.shields.io/discord/1441416992027574375?logoColor=white&color=5865F2&label=Discord" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
# 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 allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
|
||||||
|
|
||||||
# Device Compatibility
|
# Feature availability
|
||||||
|
|
||||||
| Status | Device | Features |
|
| Feature | Linux | Android |
|
||||||
| ------ | --------------------- | ---------------------------------------------------------- |
|
| ----------------------------------------------------------- | ----- | ------- |
|
||||||
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
|
| Changing Listening Mode | ✅ | ✅ |
|
||||||
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
|
| Ear detection | ✅ | ✅ |
|
||||||
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
|
| Battery status | ✅ | ✅ |
|
||||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
| Renaming AirPods <details><summary>Note for Android</summary>On Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.</details> | ✅ | ✅ |
|
||||||
|
| Loud Sound Reduction | 🔴 | ⚪ |
|
||||||
|
| Head Gestures | ⛔ | ✅ |
|
||||||
|
| Conversational Awareness | ✅ | ✅ |
|
||||||
|
| Automatically connect to AirPods | ✅ | ✅ |
|
||||||
|
| Hearing Aid | 🔴 | ⚪ |
|
||||||
|
| Transparency Mode customization | 🔴 | ⚪ |
|
||||||
|
| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ |
|
||||||
|
| <details><summary>Other accessibility configs (click to expand)</summary><ul><li>Press speed</li><li>Press and Hold duration</li><li>Noise Cancellation with single AirPod</li><li>Volume control on swipe</li><li>Volume swipe speed</li></ul></details> | 🔴 | ✅ |
|
||||||
|
| <details><summary>Other general configs</summary><ul><li>Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)</li><li>Configure call controls</li><li>Personalized volume</li><li>Loud Sound Reduction (needs <a href="#vendorid-spoofing">VendorID spoofing</a>)</li><li>Microphone side</li><li>Pause media when falling asleep (needs a recent firmware)</li><li>Enable <code>Off listening mode</code> to switch to <code>Off</code></li></ul></details> | 🔴 | ✅ |
|
||||||
|
| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ |
|
||||||
|
| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 |
|
||||||
|
| [Find My](#find-my) | ❓ | ❓ |
|
||||||
|
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
|
||||||
|
|
||||||
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).
|
| Symbol | Meaning |
|
||||||
|
| ------ | ------------------------------------------------------------------- |
|
||||||
|
| ✅ | Implemented and works well |
|
||||||
|
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
|
||||||
|
| 🔴 | Not implemented yet; planned |
|
||||||
|
| ⛔ | Will not be implemented |
|
||||||
|
| ❓ | Unknown |
|
||||||
|
|
||||||
# Key Features
|
## Find My
|
||||||
|
|
||||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
The following features related to Find My are planned, but require further RE and might need root on Android:
|
||||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
|
||||||
- **Battery Status**: Accurate battery levels
|
|
||||||
- **Head Gestures**: Answer calls just by nodding your head
|
|
||||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
|
||||||
- **Hearing Aid\***
|
|
||||||
- **Customize Transparency Mode\***
|
|
||||||
- **Multi-device connectivity\*** (upto 2 devices)
|
|
||||||
- **Other customizations**:
|
|
||||||
- Rename your AirPods
|
|
||||||
- Customize long-press actions
|
|
||||||
- All accessibility settings
|
|
||||||
- And more!
|
|
||||||
|
|
||||||
* Features marked with an asterisk require the VendorID to be change to that of Apple.
|
- Add your AirPods to the Find My network
|
||||||
|
- Play sound through charging case to find it
|
||||||
|
- Notify when leaving behind
|
||||||
|
- Toggle case charging sounds
|
||||||
|
|
||||||
# Platform Support
|
## Spatial Audio
|
||||||
|
|
||||||
## Linux
|
The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root.
|
||||||
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
|
|
||||||
|
|
||||||
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
|
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
|
||||||
|
|
||||||

|
## Heart Rate Monitoring (AirPods Pro 3 and later)
|
||||||
|
This is being worked upon, check the #reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android.
|
||||||
|
|
||||||
## Android
|
## High quality two-way audio
|
||||||
|
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
|
||||||
|
|
||||||
### Screenshots
|
Since this needs deeper integration with audio on Android, it will most likely need root.
|
||||||
|
|
||||||
| | | |
|
# Installation
|
||||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|  |  |  |
|
|
||||||
|
|
||||||
|
- [**Android**](/android/README.md)
|
||||||
|
- [**Linux**](/linux/README.md)
|
||||||
|
|
||||||
here's a very unprofessional demo video
|
# VendorID Spoofing
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
|
||||||
|
|
||||||
### Root Requirement
|
|
||||||
|
|
||||||
LibrePods **may** require root depending on your device/OS and what features you want access to:
|
|
||||||
|
|
||||||
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
|
|
||||||
- On **ColorOS/OxygenOS 16** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features (except those requiring the VendorID hook mentioned above).
|
|
||||||
- On other devices, LibrePods 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. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). Please do not comment on the issue thread. The issue has already been resolved and should be available in **Android 17** for all devices.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> This workaround with Xposed is not guaranteed to work on all devices.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
### A few notes
|
|
||||||
|
|
||||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
|
||||||
|
|
||||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
|
||||||
|
|
||||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
|
||||||
|
|
||||||
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
|
|
||||||
|
|
||||||
# Changing VendorID in the DID profile to that of Apple
|
|
||||||
|
|
||||||
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
|
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
|
||||||
|
|
||||||
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
|
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 (shown only when Xposed is available and LibrePods module is enabled).
|
||||||
|
|
||||||
## Multi-device Connectivity
|
## Multi-device Connectivity
|
||||||
|
|
||||||
@@ -116,49 +105,123 @@ Accessibility settings like customizing transparency mode (amplification, balanc
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
# Protocol and Reverse Engineering
|
||||||
|
|
||||||
|
Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark)
|
||||||
|
|
||||||
|
The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts!
|
||||||
|
|
||||||
|
# Use of AI
|
||||||
|
|
||||||
|
## Android app
|
||||||
|
|
||||||
|
These parts of the app were completely AI-generated:
|
||||||
|
- Head Gestures - all of it, including logic and the UI
|
||||||
|
- The offset setup with r2+the xposed module (both versions)
|
||||||
|
- Troubleshooter and LogCollector
|
||||||
|
|
||||||
|
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
|
||||||
|
|
||||||
|
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
||||||
|
|
||||||
|
## Linux (rewrite)
|
||||||
|
|
||||||
|
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
|
||||||
|
|
||||||
# Supporters
|
# Supporters
|
||||||
|
|
||||||
A huge thank you to everyone supporting the project!
|
A huge thank you to everyone supporting the project!
|
||||||
- @davdroman
|
|
||||||
- @tedsalmon
|
|
||||||
- @wiless
|
|
||||||
- @SmartMsg
|
|
||||||
- @lunaroyster
|
|
||||||
- @ressiwage
|
|
||||||
|
|
||||||
# Special thanks
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/davdroman">
|
||||||
|
<img src="https://github.com/davdroman.png?size=48" width="48" height="48"alt="davdroman"/><br />
|
||||||
|
@davdroman
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/tedsalmon">
|
||||||
|
<img src="https://github.com/tedsalmon.png?size=48" width="48" height="48"alt="tedsalmon"/><br />
|
||||||
|
@tedsalmon
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/wiless">
|
||||||
|
<img src="https://github.com/wiless.png?size=48" width="48" height="48"alt="wiless"/><br />
|
||||||
|
@wiless
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/SmartMsg">
|
||||||
|
<img src="https://github.com/SmartMsg.png?size=48" width="48" height="48"alt="SmartMsg"/><br />
|
||||||
|
@SmartMsg
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lunaroyster">
|
||||||
|
<img src="https://github.com/lunaroyster.png?size=48" width="48" height="48"alt="lunaroyster"/><br />
|
||||||
|
@lunaroyster
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/ressiwage">
|
||||||
|
<img src="https://github.com/ressiwage.png?size=48" width="48" height="48"alt="ressiwage"/><br />
|
||||||
|
@ressiwage
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/kkjdroid">
|
||||||
|
<img src="https://github.com/kkjdroid.png?size=48" width="48" height="48"alt="kkjdroid"/><br />
|
||||||
|
@kkjdroid
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/CitrusJoules">
|
||||||
|
<img src="https://github.com/CitrusJoules.png?size=48" width="48" height="48"alt="CitrusJoules"/><br />
|
||||||
|
@CitrusJoules
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/DanielReyesDev">
|
||||||
|
<img src="https://github.com/DanielReyesDev.png?size=48" width="48" height="48"alt="DanielReyesDev"/><br />
|
||||||
|
@DanielReyesDev
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/sumitduster">
|
||||||
|
<img src="https://github.com/sumitduster.png?size=48" width="48" height="48"alt="sumitduster"/><br />
|
||||||
|
@sumitduster
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/GrifTheDev">
|
||||||
|
<img src="https://github.com/GrifTheDev.png?size=48" width="48" height="48"alt="GrifTheDev"/><br />
|
||||||
|
@GrifTheDev
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
# Special Thanks
|
||||||
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
|
- @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))
|
- @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
|
- @devnoname120 for helping with the first root patch
|
||||||
- @timgromeyer for making the first version of the linux app
|
- @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)!
|
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
|
||||||
|
- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest!
|
||||||
|
|
||||||
# Alternates for other platforms:
|
# 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.
|
- 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 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/))
|
- 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
|
# Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
||||||
|
|||||||
67
android/README.md
Normal file
67
android/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
## Root Requirement
|
||||||
|
|
||||||
|
LibrePods *may* require root depending on your device/OS and what features you want access to:
|
||||||
|
|
||||||
|
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
|
||||||
|
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
|
||||||
|
- On other devices, LibrePods 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. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This workaround with Xposed is not guaranteed to work on all devices.
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Google Play Store
|
||||||
|
|
||||||
|
If you are using a supported device/OS combination, you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
|
||||||
|
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
|
||||||
|
|
||||||
|
### GitHub Releases
|
||||||
|
|
||||||
|
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
|
||||||
|
|
||||||
|
### As a system app (root module)
|
||||||
|
|
||||||
|
If you want LibrePods to have privileged Bluetooth permissions to
|
||||||
|
- show battery status in the system settings and widgets
|
||||||
|
- show AirPods icon in the system settings (xposed is also currently required for this)
|
||||||
|
- switch audio to phone speakers when you are not wearing your AirPods
|
||||||
|
|
||||||
|
you can install the root module. This is optional and only provides extra features, but it is not required for the app to work.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module.
|
||||||
|
|
||||||
|
## Nightly/Development Builds
|
||||||
|
|
||||||
|
Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly).
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> These builds are automatically generated from the latest code and may contain new features and bug fixes that haven't been included in a stable release yet. However, please note that they may also be less stable than official releases, so use them at your own risk.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
|
||||||
|
|  |  |  |
|
||||||
|
|  |  |  |
|
||||||
|
|  |  |  |
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
|
||||||
|
here's a very unprofessional demo video
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### A few notes
|
||||||
|
|
||||||
|
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
||||||
|
|
||||||
|
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
val appVersionName = "0.2.9"
|
val appVersionName = "0.3.0"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
@@ -41,7 +41,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 52
|
versionCode = 56
|
||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -56,7 +56,6 @@ android {
|
|||||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
@@ -65,7 +64,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ 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 dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||||
@@ -129,6 +130,7 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
|
|||||||
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||||
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
||||||
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.EqualizerScreen
|
||||||
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||||
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
|
||||||
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
|
||||||
@@ -466,7 +468,7 @@ fun Main() {
|
|||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
@@ -478,6 +480,9 @@ fun Main() {
|
|||||||
val purchaseViewModel: PurchaseViewModel = viewModel()
|
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||||
PurchaseScreen(purchaseViewModel, navController)
|
PurchaseScreen(purchaseViewModel, navController)
|
||||||
}
|
}
|
||||||
|
composable("equalizer_screen") {
|
||||||
|
if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +546,7 @@ fun Main() {
|
|||||||
Context.BIND_AUTO_CREATE
|
Context.BIND_AUTO_CREATE
|
||||||
)
|
)
|
||||||
|
|
||||||
if (airPodsService.value?.isConnected() == true) {
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* constructing and parsing packets for communication with AirPods.
|
* constructing and parsing packets for communication with AirPods.
|
||||||
*/
|
*/
|
||||||
class AACPManager {
|
class AACPManager {
|
||||||
|
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AACPManager"
|
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
object Opcodes {
|
object Opcodes {
|
||||||
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
||||||
@@ -48,7 +49,7 @@ class AACPManager {
|
|||||||
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
|
||||||
const val STEM_PRESS: Byte = 0x19
|
const val STEM_PRESS: Byte = 0x19
|
||||||
const val EQ_DATA: Byte = 0x53
|
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
|
||||||
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
||||||
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
||||||
const val SMART_ROUTING: Byte = 0x10
|
const val SMART_ROUTING: Byte = 0x10
|
||||||
@@ -56,6 +57,7 @@ class AACPManager {
|
|||||||
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?
|
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||||
|
const val CUSTOM_EQ: Byte = 0x63
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
@@ -200,6 +202,11 @@ class AACPManager {
|
|||||||
var eqOnMedia: Boolean = false
|
var eqOnMedia: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var customEqCallback: ((CustomEq) -> Unit)? = null
|
||||||
|
|
||||||
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||||
return controlCommandStatusList.find { it.identifier == identifier }
|
return controlCommandStatusList.find { it.identifier == identifier }
|
||||||
}
|
}
|
||||||
@@ -236,7 +243,9 @@ 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 onHeadphoneAccommodationReceived(eqData: FloatArray)
|
||||||
|
fun onCustomEqReceived(customEq: CustomEq)
|
||||||
|
fun onCapabilitiesReceived(capabilities: List<Capability>)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
@@ -549,18 +558,18 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.EQ_DATA -> {
|
Opcodes.HEADPHONE_ACCOMMODATION -> {
|
||||||
if (packet.size != 140) {
|
if (packet.size != 140) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
|
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packet[6] != 0x84.toByte()) {
|
if (packet[6] != 0x84.toByte()) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -583,7 +592,7 @@ class AACPManager {
|
|||||||
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||||
)
|
)
|
||||||
|
|
||||||
callback?.onEQPacketReceived(eqData)
|
callback?.onHeadphoneAccommodationReceived(eqData)
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.INFORMATION -> {
|
Opcodes.INFORMATION -> {
|
||||||
@@ -592,6 +601,13 @@ class AACPManager {
|
|||||||
callback?.onDeviceInformationReceived(information)
|
callback?.onDeviceInformationReceived(information)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Opcodes.CUSTOM_EQ -> {
|
||||||
|
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
|
||||||
|
customEq = parseCustomEqPacket(packet)
|
||||||
|
customEqCallback?.invoke(customEq)
|
||||||
|
callback?.onCustomEqReceived(customEq)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
@@ -1143,7 +1159,7 @@ class AACPManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
@@ -1297,4 +1313,38 @@ class AACPManager {
|
|||||||
version3 = strings.getOrNull(10) ?: "",
|
version3 = strings.getOrNull(10) ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
|
||||||
|
return sendDataPacket(customEq.toPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
|
||||||
|
val data = packet.sliceArray(6 until packet.size)
|
||||||
|
|
||||||
|
if (data.size < 7) {
|
||||||
|
Log.e(TAG, "custom EQ packet length less than 7, returning default")
|
||||||
|
return CustomEq(1, 50, 50, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lengthLow = data[0].toInt() and 0xFF
|
||||||
|
val lengthHigh = data[1].toInt() and 0xFF
|
||||||
|
|
||||||
|
val length = (lengthHigh shl 8) or lengthLow
|
||||||
|
|
||||||
|
if (length != 5) {
|
||||||
|
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = data[3].toInt()
|
||||||
|
val low = data[4].toInt()
|
||||||
|
val mid = data[5].toInt()
|
||||||
|
val high = data[6].toInt()
|
||||||
|
|
||||||
|
return CustomEq(
|
||||||
|
state,
|
||||||
|
low,
|
||||||
|
mid,
|
||||||
|
high
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,234 +16,196 @@
|
|||||||
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
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothSocket
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
enum class ATTHandles(val value: Int) {
|
enum class ATTHandles(val value: Int) {
|
||||||
TRANSPARENCY(0x18),
|
TRANSPARENCY(0x18),
|
||||||
LOUD_SOUND_REDUCTION(0x1B),
|
LOUD_SOUND_REDUCTION(0x1B),
|
||||||
HEARING_AID(0x2A),
|
HEARING_AID(0x2A)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ATTCCCDHandles(val value: Int) {
|
enum class ATTCCCDHandles(val value: Int) {
|
||||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
class ATTManagerv2 {
|
||||||
companion object {
|
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||||
private const val TAG = "ATTManager"
|
|
||||||
|
|
||||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
|
||||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
|
||||||
}
|
|
||||||
|
|
||||||
var socket: BluetoothSocket? = null
|
private val readerRunning = AtomicBoolean(false)
|
||||||
private var input: InputStream? = null
|
private var readerThread: Thread? = null
|
||||||
private var output: OutputStream? = null
|
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
|
||||||
private var notificationJob: Job? = null
|
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
fun startReader() {
|
||||||
fun connect() {
|
if (readerRunning.getAndSet(true)) return
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
|
||||||
|
|
||||||
|
readerThread = Thread {
|
||||||
try {
|
try {
|
||||||
socket = createBluetoothSocket(adapter, device, uuid)
|
runReaderLoop()
|
||||||
} catch (e: Exception) {
|
} catch (t: Throwable) {
|
||||||
Log.w(TAG, "Failed to create socket")
|
Log.e(TAG, "reader thread crashed: ${t.message}", t)
|
||||||
|
} finally {
|
||||||
|
readerRunning.set(false)
|
||||||
|
Log.d(TAG, "reader thread stopped")
|
||||||
}
|
}
|
||||||
|
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
|
||||||
|
Log.d(TAG, "reader started")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopReader() {
|
||||||
|
readerRunning.set(false)
|
||||||
|
readerThread?.interrupt()
|
||||||
|
readerThread = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||||
|
onNotificationReceived = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableNotification(handle: ATTCCCDHandles) {
|
||||||
|
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharacteristic(handle: ATTHandles): ByteArray? {
|
||||||
|
val storedValue = characteristicList[handle]
|
||||||
|
return if (storedValue?.isNotEmpty() != true) {
|
||||||
|
readCharacteristic(handle)
|
||||||
|
} else storedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
|
||||||
|
val socket = BluetoothConnectionManager.getATTSocket() ?: return null
|
||||||
try {
|
try {
|
||||||
socket!!.connect()
|
val output = socket.outputStream
|
||||||
|
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
|
||||||
|
synchronized(output) {
|
||||||
|
output.write(pdu)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
|
||||||
|
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
val value = resp.copyOfRange(1, resp.size)
|
||||||
|
characteristicList[handle] = value
|
||||||
|
return value
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "ATT socket failed to connect")
|
Log.e(TAG, "error reading characteristic: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||||
|
characteristicList[handle] = data
|
||||||
|
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||||
|
val socket = BluetoothConnectionManager.getATTSocket() ?: return
|
||||||
|
try {
|
||||||
|
val output = socket.outputStream
|
||||||
|
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
|
||||||
|
synchronized(output) {
|
||||||
|
output.write(pdu)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
|
||||||
|
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input = socket!!.inputStream
|
|
||||||
output = socket!!.outputStream
|
|
||||||
Log.d(TAG, "Connected to ATT")
|
|
||||||
|
|
||||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
while (socket?.isConnected == true) {
|
|
||||||
try {
|
|
||||||
val pdu = readPDU()
|
|
||||||
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
|
|
||||||
// notification -> dispatch to listeners
|
|
||||||
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
|
|
||||||
val value = pdu.copyOfRange(3, pdu.size)
|
|
||||||
listeners[handle]?.forEach { listener ->
|
|
||||||
try {
|
|
||||||
listener(value)
|
|
||||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// not a notification -> treat as a response for pending request(s)
|
|
||||||
responses.put(pdu)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
|
||||||
if (socket?.isConnected != true) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnected() {
|
||||||
|
characteristicList.clear()
|
||||||
|
stopReader()
|
||||||
|
val socket = BluetoothConnectionManager.getATTSocket() ?: return
|
||||||
try {
|
try {
|
||||||
notificationJob?.cancel()
|
socket.close()
|
||||||
socket?.close()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
Log.w(TAG, "error closing socket: ${e.message}")
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "ATT disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
private fun runReaderLoop() {
|
||||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
|
||||||
|
Log.w(TAG, "ATT socket not available. stopping reader")
|
||||||
|
readerRunning.set(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
val input = socket.inputStream
|
||||||
listeners[handle.value]?.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableNotifications(handle: ATTHandles) {
|
|
||||||
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun read(handle: ATTHandles): ByteArray {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
|
|
||||||
writeRaw(pdu)
|
|
||||||
// wait for response placed into responses queue by the reader coroutine
|
|
||||||
return readResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(handle: ATTHandles, value: ByteArray) {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
|
||||||
writeRaw(pdu)
|
|
||||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
|
||||||
try {
|
|
||||||
readResponse()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "No write response received: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(handle: ATTCCCDHandles, value: ByteArray) {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
|
||||||
writeRaw(pdu)
|
|
||||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
|
||||||
try {
|
|
||||||
readResponse()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "No write response received: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeRaw(pdu: ByteArray) {
|
|
||||||
if (output == null) return
|
|
||||||
output?.write(pdu)
|
|
||||||
output?.flush()
|
|
||||||
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// rename / specialize: read raw PDU directly from input stream (blocking)
|
|
||||||
private fun readPDU(): ByteArray {
|
|
||||||
val inp = input ?: throw IllegalStateException("Not connected")
|
|
||||||
val buffer = ByteArray(512)
|
val buffer = ByteArray(512)
|
||||||
val len = inp.read(buffer)
|
|
||||||
|
while (readerRunning.get()) {
|
||||||
|
try {
|
||||||
|
val len = input.read(buffer)
|
||||||
if (len == -1) {
|
if (len == -1) {
|
||||||
disconnect()
|
Log.w(TAG, "ATT input stream ended")
|
||||||
throw IllegalStateException("End of stream reached")
|
break
|
||||||
}
|
}
|
||||||
val data = buffer.copyOfRange(0, len)
|
val data = buffer.copyOfRange(0, len)
|
||||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
if (data.isEmpty()) continue
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for a response PDU produced by the background reader
|
val opcode = data[0]
|
||||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||||
|
queue.offer(data)
|
||||||
|
|
||||||
|
if (opcode == 0x1B.toByte()) {
|
||||||
|
if (data.size >= 3) {
|
||||||
|
val handle = data[1]
|
||||||
|
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
|
||||||
|
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
|
||||||
try {
|
try {
|
||||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
onNotificationReceived?.invoke(handle, value)
|
||||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
} catch (t: Throwable) {
|
||||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
|
||||||
return resp.copyOfRange(1, resp.size)
|
}
|
||||||
} catch (e: InterruptedException) {
|
} else {
|
||||||
Thread.currentThread().interrupt()
|
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
throw IllegalStateException("Interrupted while waiting for ATT response", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
|
||||||
val type = 3 // L2CAP
|
|
||||||
val constructorSpecs = listOf(
|
|
||||||
arrayOf(adapter, device, type, true, true, 31, uuid),
|
|
||||||
arrayOf(device, type, true, true, 31, uuid),
|
|
||||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
|
||||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
|
||||||
arrayOf(type, true, true, device, 31, uuid)
|
|
||||||
)
|
|
||||||
|
|
||||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
|
||||||
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
|
|
||||||
|
|
||||||
constructors.forEachIndexed { index, constructor ->
|
|
||||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
|
||||||
Log.d("ATTManager", "Constructor $index: ($params)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastException: Exception? = null
|
|
||||||
var attemptedConstructors = 0
|
|
||||||
|
|
||||||
for ((index, params) in constructorSpecs.withIndex()) {
|
|
||||||
try {
|
|
||||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
|
||||||
attemptedConstructors++
|
|
||||||
|
|
||||||
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(TAG, "error in reader loop: ${e.message}", e)
|
||||||
lastException = e
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
readerRunning.set(false)
|
||||||
Log.e("ATTManager", errorMessage)
|
}
|
||||||
throw lastException ?: IllegalStateException(errorMessage)
|
|
||||||
|
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
|
||||||
|
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||||
|
return try {
|
||||||
|
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,23 +18,22 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
object BluetoothConnectionManager {
|
object BluetoothConnectionManager {
|
||||||
private const val TAG = "BluetoothConnectionManager"
|
private var aacpSocket: BluetoothSocket? = null
|
||||||
|
private var attSocket: BluetoothSocket? = null
|
||||||
|
|
||||||
private var currentSocket: BluetoothSocket? = null
|
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
|
||||||
private var currentDevice: BluetoothDevice? = null
|
BluetoothConnectionManager.aacpSocket = aacpSocket
|
||||||
|
BluetoothConnectionManager.attSocket = attSocket
|
||||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
|
||||||
currentSocket = socket
|
|
||||||
currentDevice = device
|
|
||||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentSocket(): BluetoothSocket? {
|
fun getAACPSocket(): BluetoothSocket? {
|
||||||
return currentSocket
|
return aacpSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getATTSocket(): BluetoothSocket? {
|
||||||
|
return attSocket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
|
||||||
|
enum class CustomEqBand { LOW, MID, HIGH }
|
||||||
|
|
||||||
|
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean {
|
||||||
|
return state == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toPacket(): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
||||||
|
0x05, 0x00, // length (LE)
|
||||||
|
0x01, state.toByte(),
|
||||||
|
low.toByte(), mid.toByte(), high.toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(low in 0..100) { "low must be between 0 and 100, was $low" }
|
||||||
|
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
|
||||||
|
require(high in 0..100) { "high must be between 0 and 100, was $high" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ 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.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
|
||||||
@@ -138,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendHearingAidSettings(
|
fun sendHearingAidSettings(
|
||||||
attManager: ATTManager,
|
currentData: ByteArray,
|
||||||
hearingAidSettings: HearingAidSettings,
|
hearingAidSettings: HearingAidSettings,
|
||||||
debounceJob: MutableState<Job?>
|
debounceJob: MutableState<Job?>,
|
||||||
|
sender: (ATTHandles, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
debounceJob.value?.cancel()
|
debounceJob.value?.cancel()
|
||||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
try {
|
try {
|
||||||
val currentData = attManager.read(ATTHandles.HEARING_AID)
|
|
||||||
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
if (currentData.size < 104) {
|
if (currentData.size < 104) {
|
||||||
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
||||||
@@ -184,7 +183,7 @@ fun sendHearingAidSettings(
|
|||||||
|
|
||||||
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
attManager.write(ATTHandles.HEARING_AID, currentData)
|
sender(ATTHandles.HEARING_AID, currentData)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import android.os.Parcelable
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
// TODO: Remove everything but Battery-related stuff
|
||||||
|
|
||||||
enum class Enums(val value: ByteArray) {
|
enum class Enums(val value: ByteArray) {
|
||||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
NOISE_CANCELLATION(byteArrayOf(0x0d)),
|
||||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||||
@@ -81,12 +83,12 @@ 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 EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
|
||||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||||
}
|
}
|
||||||
|
|
||||||
class EarDetection {
|
class EarDetection {
|
||||||
private val notificationBit = Capabilities.EAR_DETECTION
|
private val notificationBit = 6.toByte()
|
||||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||||
|
|
||||||
var status: List<Byte> = listOf(0x01, 0x01)
|
var status: List<Byte> = listOf(0x01, 0x01)
|
||||||
@@ -243,13 +245,6 @@ class AirPodsNotifications {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Capabilities {
|
|
||||||
companion object {
|
|
||||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
|
||||||
val EAR_DETECTION = byteArrayOf(0x06)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||||
if (data.size <= 60) return false
|
if (data.size <= 60) return false
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ data class TransparencySettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
|
||||||
|
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
|
||||||
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
val enabled = buffer.float
|
val enabled = buffer.float
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fun AudioSettings(
|
|||||||
conversationalAwarenessCapability: Boolean,
|
conversationalAwarenessCapability: Boolean,
|
||||||
loudSoundReductionCapability: Boolean,
|
loudSoundReductionCapability: Boolean,
|
||||||
adaptiveAudioCapability: Boolean,
|
adaptiveAudioCapability: Boolean,
|
||||||
|
customEqCapability: Boolean,
|
||||||
|
|
||||||
adaptiveVolumeChecked: Boolean,
|
adaptiveVolumeChecked: Boolean,
|
||||||
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||||
@@ -157,6 +158,20 @@ fun AudioSettings(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (customEqCapability) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "equalizer_screen",
|
||||||
|
name = stringResource(R.string.equalizer),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,6 +185,7 @@ fun AudioSettingsPreview() {
|
|||||||
conversationalAwarenessCapability = true,
|
conversationalAwarenessCapability = true,
|
||||||
loudSoundReductionCapability = true,
|
loudSoundReductionCapability = true,
|
||||||
adaptiveAudioCapability = true,
|
adaptiveAudioCapability = true,
|
||||||
|
customEqCapability = true,
|
||||||
adaptiveVolumeChecked = true,
|
adaptiveVolumeChecked = true,
|
||||||
onAdaptiveVolumeCheckedChange = { },
|
onAdaptiveVolumeCheckedChange = { },
|
||||||
conversationalAwarenessChecked = true,
|
conversationalAwarenessChecked = true,
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
drawRect(color)
|
drawRect(color)
|
||||||
} else {
|
} else {
|
||||||
if (isPressed) {
|
if (isPressed && enabled) {
|
||||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||||
drawRect(Color.White.copy(alpha = 0.2f))
|
drawRect(Color.White.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
@@ -264,9 +264,15 @@ half4 main(float2 coord) {
|
|||||||
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 = {
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
0f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
offsetAnimation.animateTo(
|
offsetAnimation.animateTo(
|
||||||
Offset.Zero,
|
Offset.Zero,
|
||||||
@@ -275,9 +281,11 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
launch {
|
launch {
|
||||||
@@ -288,12 +296,14 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
onDragStop()
|
onDragStop()
|
||||||
},
|
},
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
HapticFeedbackType.SegmentFrequentTick
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
@@ -302,6 +312,7 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import android.content.IntentFilter
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -374,6 +375,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
|
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
|
||||||
|
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||||
videoView.setVideoURI(videoUri)
|
videoView.setVideoURI(videoUri)
|
||||||
videoView.setOnPreparedListener { mediaPlayer ->
|
videoView.setOnPreparedListener { mediaPlayer ->
|
||||||
mediaPlayer.isLooping = true
|
mediaPlayer.isLooping = true
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -137,6 +138,7 @@ class PopupWindow(
|
|||||||
updateBatteryStatus(batteryNotification)
|
updateBatteryStatus(batteryNotification)
|
||||||
|
|
||||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||||
|
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||||
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
||||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||||
vid.start()
|
vid.start()
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ package me.kavishdevar.librepods.presentation.screens
|
|||||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
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.Arrangement
|
||||||
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -51,6 +55,7 @@ import androidx.compose.runtime.remember
|
|||||||
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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@@ -64,6 +69,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
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.core.net.toUri
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
@@ -93,6 +99,7 @@ import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
|||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@@ -170,6 +177,44 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
|||||||
}
|
}
|
||||||
} else Modifier)) {
|
} else Modifier)) {
|
||||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
||||||
|
|
||||||
|
item(key = "play_update_banner") {
|
||||||
|
if (state.timeUntilFOSSPremiumExpiry > 0L) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.clickable {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:".toUri()
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(emailIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(key = "battery") {
|
item(key = "battery") {
|
||||||
BatteryView(
|
BatteryView(
|
||||||
batteryList = state.battery,
|
batteryList = state.battery,
|
||||||
@@ -320,6 +365,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
|||||||
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
||||||
loudSoundReductionCapability = loudSoundReductionCapability,
|
loudSoundReductionCapability = loudSoundReductionCapability,
|
||||||
adaptiveAudioCapability = adaptiveAudioCapability,
|
adaptiveAudioCapability = adaptiveAudioCapability,
|
||||||
|
customEqCapability = true,
|
||||||
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
||||||
onAdaptiveVolumeCheckedChange = { checked ->
|
onAdaptiveVolumeCheckedChange = { checked ->
|
||||||
viewModel.setControlCommandBoolean(
|
viewModel.setControlCommandBoolean(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
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
|
||||||
@@ -91,6 +92,7 @@ import me.kavishdevar.librepods.presentation.components.StyledSlider
|
|||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -147,7 +149,39 @@ fun AppSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (state.timeUntilFOSSPremiumExpiry > 0L) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.clickable {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:".toUri()
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(emailIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state.connectionSuccessful) {
|
if (state.connectionSuccessful) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
title = stringResource(R.string.widget),
|
title = stringResource(R.string.widget),
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
/*
|
||||||
|
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.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.visible
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
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.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.effects.lens
|
||||||
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@Composable
|
||||||
|
fun EqualizerScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val customEq = state.customEq
|
||||||
|
val enabled = customEq.isEnabled()
|
||||||
|
|
||||||
|
val recommendedString = stringResource(R.string.recommended)
|
||||||
|
val customString = stringResource(R.string.custom)
|
||||||
|
|
||||||
|
val eqStateOptions = remember(state.customEq) {
|
||||||
|
listOf(
|
||||||
|
SelectItem(
|
||||||
|
name = recommendedString,
|
||||||
|
selected = !enabled,
|
||||||
|
onClick = { viewModel.setCustomEqEnabled(false) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = customString,
|
||||||
|
selected = enabled,
|
||||||
|
onClick = { viewModel.setCustomEqEnabled(true) }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.equalizer)
|
||||||
|
) { spacerHeight ->
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val height = 200.dp
|
||||||
|
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||||
|
|
||||||
|
val offsets = remember(state.customEq) {
|
||||||
|
listOf(
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
StyledSelectList(items = eqStateOptions)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
Crossfade (
|
||||||
|
customEq.isEnabled()
|
||||||
|
) { visible ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.visible(visible),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
val dashColor =
|
||||||
|
if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
|
||||||
|
// LaunchedEffect(offsets[0].floatValue, offsets[1].floatValue, offsets[2].floatValue) {
|
||||||
|
// val low = ((offsets[0].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
// val mid = ((offsets[1].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
// val high = ((offsets[2].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
// Log.d("EqualizerScreen", "$low, $mid, $high")
|
||||||
|
// viewModel.setCustomEq(
|
||||||
|
// low = low,
|
||||||
|
// mid = mid,
|
||||||
|
// high = high
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
LaunchedEffect(offsets) {
|
||||||
|
snapshotFlow {
|
||||||
|
Triple(
|
||||||
|
offsets[0].floatValue,
|
||||||
|
offsets[1].floatValue,
|
||||||
|
offsets[2].floatValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
|
||||||
|
.collect { (lowF, midF, highF) ->
|
||||||
|
val low =
|
||||||
|
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val mid =
|
||||||
|
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val high =
|
||||||
|
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
|
||||||
|
viewModel.setCustomEq(low, mid, high)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(42.dp))
|
||||||
|
// Row(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .padding(18.dp),
|
||||||
|
// verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
// ) {
|
||||||
|
// Box(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .size(64.dp)
|
||||||
|
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
|
||||||
|
// )
|
||||||
|
// Column(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .weight(1f),
|
||||||
|
// verticalArrangement = Arrangement.Center
|
||||||
|
// ) {
|
||||||
|
// Text(
|
||||||
|
// text = "Written into Changes",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 16.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Bold,
|
||||||
|
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Text(
|
||||||
|
// text = "Avalon Emerson",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 14.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Normal,
|
||||||
|
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// val paused = remember { mutableStateOf(false) }
|
||||||
|
// Box(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .size(48.dp)
|
||||||
|
// .background(Color(0x600091FF), CircleShape)
|
||||||
|
// .clickable(
|
||||||
|
// interactionSource = remember { MutableInteractionSource() },
|
||||||
|
// indication = null,
|
||||||
|
// ) {
|
||||||
|
// paused.value = !paused.value
|
||||||
|
// },
|
||||||
|
// contentAlignment = Alignment.Center
|
||||||
|
// ) {
|
||||||
|
// Crossfade(
|
||||||
|
// targetState = paused.value,
|
||||||
|
// label = "media_icon"
|
||||||
|
// ) { p ->
|
||||||
|
// Text(
|
||||||
|
// text = if (p) "" else "",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 24.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Normal,
|
||||||
|
// color = Color(0xFF0091FF),
|
||||||
|
// textAlign = TextAlign.Center
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// HorizontalDivider(
|
||||||
|
// thickness = 1.dp,
|
||||||
|
// color = Color(0x40888888),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .padding(horizontal = 20.dp)
|
||||||
|
// .padding(bottom = 16.dp)
|
||||||
|
// )
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
fun colorFromY(y: Float): Color {
|
||||||
|
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
|
||||||
|
val stops = listOf(
|
||||||
|
0.0f to Color(0xFFFFA300),
|
||||||
|
0.25f to Color(0xFFFCE600),
|
||||||
|
0.5f to Color(0xFF00FAAF),
|
||||||
|
0.75f to Color(0xFF00FAFF),
|
||||||
|
1.0f to Color(0xFF00B5FF)
|
||||||
|
)
|
||||||
|
val (start, end) = stops.zipWithNext()
|
||||||
|
.first { f <= it.second.first }
|
||||||
|
val c = (f - start.first) / (end.first - start.first)
|
||||||
|
return lerp(start.second, end.second, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pathBrush(
|
||||||
|
startY: Float,
|
||||||
|
endY: Float,
|
||||||
|
): Brush {
|
||||||
|
val stops = (0..20).map { i ->
|
||||||
|
val t = i / 20f
|
||||||
|
val y = lerp(startY, endY, t)
|
||||||
|
t to colorFromY(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Brush.linearGradient(
|
||||||
|
colorStops = stops.toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().layerBackdrop(backdrop)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
val dashCount = (height / 10.dp).toInt()
|
||||||
|
repeat(3) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
for (i in 1..(dashCount)) {
|
||||||
|
val t = i.toFloat() / dashCount
|
||||||
|
val centerDistance = abs(0.5f - t)
|
||||||
|
val alpha = 1f - (centerDistance * 2f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(9.dp)
|
||||||
|
.width(0.75.dp)
|
||||||
|
.background(
|
||||||
|
dashColor.copy(alpha),
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
val canvasWidth = size.width
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = backgroundColor,
|
||||||
|
start = Offset(
|
||||||
|
x = 0f,
|
||||||
|
y = offsets[0].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = offsets[0].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 10f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = colorFromY(offsets[0].floatValue),
|
||||||
|
start = Offset(
|
||||||
|
x = 0f,
|
||||||
|
y = offsets[0].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = offsets[0].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 8f
|
||||||
|
)
|
||||||
|
|
||||||
|
val lowToMidPath = Path()
|
||||||
|
lowToMidPath.moveTo(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = offsets[0].floatValue + maxOffset
|
||||||
|
)
|
||||||
|
lowToMidPath.cubicTo(
|
||||||
|
x1 = canvasWidth * 1 / 6f + 108.dp.value,
|
||||||
|
y1 = offsets[0].floatValue + maxOffset,
|
||||||
|
x2 = canvasWidth * 0.5f - 108.dp.value,
|
||||||
|
y2 = offsets[1].floatValue + maxOffset,
|
||||||
|
x3 = canvasWidth * 0.5f,
|
||||||
|
y3 = offsets[1].floatValue + maxOffset
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
color = backgroundColor,
|
||||||
|
path = lowToMidPath,
|
||||||
|
style = Stroke(width = 10f)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
brush = pathBrush(
|
||||||
|
offsets[0].floatValue,
|
||||||
|
offsets[1].floatValue
|
||||||
|
),
|
||||||
|
path = lowToMidPath,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
|
||||||
|
val midToHighPath = Path()
|
||||||
|
midToHighPath.moveTo(
|
||||||
|
x = 0.5f * canvasWidth,
|
||||||
|
y = offsets[1].floatValue + maxOffset
|
||||||
|
)
|
||||||
|
midToHighPath.cubicTo(
|
||||||
|
x1 = canvasWidth * 0.5f + 108.dp.value,
|
||||||
|
y1 = offsets[1].floatValue + maxOffset,
|
||||||
|
x2 = canvasWidth * 5 / 6f - 108.dp.value,
|
||||||
|
y2 = offsets[2].floatValue + maxOffset,
|
||||||
|
x3 = canvasWidth * 5 / 6f,
|
||||||
|
y3 = offsets[2].floatValue + maxOffset
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
color = backgroundColor,
|
||||||
|
path = midToHighPath,
|
||||||
|
style = Stroke(width = 10f)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
brush = pathBrush(
|
||||||
|
offsets[1].floatValue,
|
||||||
|
offsets[2].floatValue
|
||||||
|
),
|
||||||
|
path = midToHighPath,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = backgroundColor,
|
||||||
|
start = Offset(
|
||||||
|
x = 5 / 6f * canvasWidth,
|
||||||
|
y = offsets[2].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1f * canvasWidth,
|
||||||
|
y = offsets[2].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 10f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = colorFromY(offsets[2].floatValue),
|
||||||
|
start = Offset(
|
||||||
|
x = 5 / 6f * canvasWidth,
|
||||||
|
y = offsets[2].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1f * canvasWidth,
|
||||||
|
y = offsets[2].floatValue + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 8f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Low".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Mid".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "High".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
for (i in 0..2) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
val pressed = remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = 0,
|
||||||
|
y = offsets[i].floatValue.roundToInt()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
pressed.value
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.then(
|
||||||
|
if (it) {
|
||||||
|
Modifier.drawBackdrop(
|
||||||
|
backdrop = backdrop,
|
||||||
|
shape = { CircleShape },
|
||||||
|
highlight = {
|
||||||
|
Highlight.Ambient
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
drawCircle(
|
||||||
|
color = Color.White.copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
radius = size.height
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = colorFromY(
|
||||||
|
offsets[i].floatValue
|
||||||
|
),
|
||||||
|
style = Stroke(2.dp.value),
|
||||||
|
radius = size.height / 2
|
||||||
|
)
|
||||||
|
},
|
||||||
|
effects = {
|
||||||
|
lens(
|
||||||
|
refractionHeight = 32f.dp.value,
|
||||||
|
refractionAmount = size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(18.dp)
|
||||||
|
.background(
|
||||||
|
colorFromY(offsets[i].floatValue),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
2.5.dp,
|
||||||
|
backgroundColor,
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
.draggable(
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
state = rememberDraggableState { delta ->
|
||||||
|
offsets[i].floatValue =
|
||||||
|
(offsets[i].floatValue + delta).coerceIn(
|
||||||
|
-maxOffset,
|
||||||
|
maxOffset
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDragStarted = {
|
||||||
|
pressed.value = true
|
||||||
|
},
|
||||||
|
onDragStopped = {
|
||||||
|
pressed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
offsets[0].floatValue = 0f
|
||||||
|
offsets[1].floatValue = 0f
|
||||||
|
offsets[2].floatValue = 0f
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isInteractive = false,
|
||||||
|
surfaceColor = backgroundColor,
|
||||||
|
enabled = resetButtonEnabled.value
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reset),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = if (!offsets.all { it.floatValue == 0f }) Color(0xFF0093FF) else Color.Gray
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,13 +32,12 @@ 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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
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.saveable.rememberSaveable
|
||||||
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
|
||||||
@@ -48,22 +47,17 @@ import dev.chrisbanes.haze.HazeState
|
|||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
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.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@@ -74,64 +68,42 @@ 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 state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.adjustments)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(verticalScrollState)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
|
||||||
|
|
||||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||||
|
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||||
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember { mutableStateOf(
|
||||||
mutableStateOf(
|
|
||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
leftAmplification = 0f,
|
||||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
rightAmplification = 0f,
|
||||||
leftTone = toneSliderValue.floatValue,
|
leftTone = 0f,
|
||||||
rightTone = toneSliderValue.floatValue,
|
rightTone = 0f,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = false,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = false,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
leftAmbientNoiseReduction = 0f,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
rightAmbientNoiseReduction = 0f,
|
||||||
netAmplification = amplificationSliderValue.floatValue,
|
netAmplification = 0f,
|
||||||
balance = balanceSliderValue.floatValue,
|
balance = 0f,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = 0f
|
||||||
)
|
)
|
||||||
)
|
) }
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
balanceSliderValue.floatValue = parsed.balance
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
toneSliderValue.floatValue = parsed.leftTone
|
||||||
@@ -140,25 +112,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
initialized.value = true
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(
|
||||||
if (!initialLoadComplete.value) {
|
amplificationSliderValue.floatValue,
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
balanceSliderValue.floatValue,
|
||||||
return@LaunchedEffect
|
toneSliderValue.floatValue,
|
||||||
}
|
conversationBoostEnabled.value,
|
||||||
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
if (!initialReadSucceeded.value) {
|
ownVoiceAmplification.floatValue
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
) {
|
||||||
return@LaunchedEffect
|
if (!initialized.value) return@LaunchedEffect
|
||||||
}
|
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -175,53 +141,20 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
Column(
|
||||||
try {
|
modifier = Modifier
|
||||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
.hazeSource(hazeState)
|
||||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
var parsedSettings: HearingAidSettings? = null
|
.verticalScroll(verticalScrollState)
|
||||||
for (attempt in 1..3) {
|
.padding(horizontal = 16.dp),
|
||||||
initialReadAttempts.intValue = attempt
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
try {
|
) {
|
||||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsedSettings.balance
|
|
||||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
|
||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
|
||||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
@@ -235,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.w(TAG, "transparency parse failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ 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 me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PurchaseScreen(
|
fun PurchaseScreen(
|
||||||
@@ -199,7 +199,7 @@ fun PurchaseScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (BuildConfig.FLAVOR == "xposed") {
|
if (XposedState.isAvailable) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
color = Color(0x40888888),
|
color = Color(0x40888888),
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
val textFieldState = rememberTextFieldState()
|
val name = sharedPreferences.getString("name", "")?: ""
|
||||||
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
|
val textFieldState = rememberTextFieldState(initialText = name)
|
||||||
|
|
||||||
LaunchedEffect(textFieldState.text) {
|
LaunchedEffect(textFieldState.text) {
|
||||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||||
viewModel.setName(textFieldState.text.toString())
|
viewModel.setName(textFieldState.text.toString())
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
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.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@@ -64,17 +65,14 @@ 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 me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
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.data.TransparencySettings
|
import me.kavishdevar.librepods.data.TransparencySettings
|
||||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "TransparencySettings"
|
private const val TAG = "TransparencySettings"
|
||||||
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
Spacer(modifier = Modifier.height(topPadding))
|
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 = rememberSaveable { mutableStateOf(false) }
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
val eq = rememberSaveable(
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) { mutableStateOf(FloatArray(8)) }
|
||||||
|
val phoneMediaEQ = rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
|
|
||||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val transparencySettings = remember {
|
val transparencySettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
toneSliderValue.floatValue,
|
toneSliderValue.floatValue,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
ambientNoiseReductionSliderValue.floatValue,
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
eq.value,
|
eq.value
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value
|
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Initial device read not successful yet - skipping send until read succeeds"
|
|
||||||
)
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
transparencySettings.value = TransparencySettings(
|
transparencySettings.value = TransparencySettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
leftEQ = eq.value,
|
leftEQ = eq.value,
|
||||||
@@ -189,38 +180,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(state.transparencyData) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||||
try {
|
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
|
||||||
val aacpEQ = state.eqData
|
|
||||||
if (aacpEQ.isNotEmpty()) {
|
|
||||||
eq.value = aacpEQ.copyOf()
|
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsedSettings: TransparencySettings? = null
|
|
||||||
for (attempt in 1..3) {
|
|
||||||
initialReadAttempts.intValue = attempt
|
|
||||||
try {
|
|
||||||
val data = state.transparencyData
|
|
||||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||||
enabled.value = parsedSettings.enabled
|
enabled.value = parsedSettings.enabled
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||||
@@ -229,19 +190,10 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ambientNoiseReductionSliderValue.floatValue =
|
ambientNoiseReductionSliderValue.floatValue =
|
||||||
parsedSettings.leftAmbientNoiseReduction
|
parsedSettings.leftAmbientNoiseReduction
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
|
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||||
eq.value = parsedSettings.leftEQ.copyOf()
|
eq.value = parsedSettings.leftEQ.copyOf()
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
}
|
||||||
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.vendorIdHook) {
|
if (state.vendorIdHook) {
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
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.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
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
|
||||||
@@ -57,34 +58,19 @@ 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 kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
import java.io.IOException
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen() {
|
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val state by viewModel.uiState.collectAsState()
|
||||||
if (attManager == null) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
@@ -112,18 +98,31 @@ fun UpdateHearingTestScreen() {
|
|||||||
),
|
),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = rememberSaveable(
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
val rightEQ = rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
|
||||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -145,10 +144,8 @@ fun UpdateHearingTestScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
@@ -158,46 +155,24 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
leftAmplification.floatValue = parsed.leftAmplification
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
rightAmplification.floatValue = parsed.rightAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
|
initialized.value = true
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
leftEQ.value,
|
leftEQ.value,
|
||||||
rightEQ.value,
|
rightEQ.value,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value,
|
|
||||||
leftAmplification.floatValue,
|
leftAmplification.floatValue,
|
||||||
rightAmplification.floatValue,
|
rightAmplification.floatValue,
|
||||||
tone.floatValue,
|
tone.floatValue,
|
||||||
ambientNoiseReduction.floatValue,
|
ambientNoiseReduction.floatValue,
|
||||||
ownVoiceAmplification.floatValue
|
ownVoiceAmplification.floatValue
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Initial device read not successful yet - skipping send until read succeeds"
|
|
||||||
)
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -214,55 +189,7 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
|
||||||
try {
|
|
||||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
|
|
||||||
var parsedSettings: HearingAidSettings? = null
|
|
||||||
for (attempt in 1..3) {
|
|
||||||
initialReadAttempts.intValue = attempt
|
|
||||||
try {
|
|
||||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
|
||||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
|
||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
|
||||||
tone.floatValue = parsedSettings.leftTone
|
|
||||||
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
|
||||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
|
||||||
leftAmplification.floatValue = parsedSettings.leftAmplification
|
|
||||||
rightAmplification.floatValue = parsedSettings.rightAmplification
|
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies =
|
val frequencies =
|
||||||
|
|||||||
@@ -24,21 +24,24 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
import me.kavishdevar.librepods.data.AirPodsModels
|
import me.kavishdevar.librepods.data.AirPodsModels
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
@@ -47,6 +50,7 @@ import me.kavishdevar.librepods.data.BatteryComponent
|
|||||||
import me.kavishdevar.librepods.data.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.data.Capability
|
import me.kavishdevar.librepods.data.Capability
|
||||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
@@ -93,7 +97,10 @@ data class AirPodsUiState(
|
|||||||
|
|
||||||
val dynamicEndOfCharge: Boolean = false,
|
val dynamicEndOfCharge: Boolean = false,
|
||||||
|
|
||||||
val connectionSuccessful: Boolean = false
|
val connectionSuccessful: Boolean = false,
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||||
|
|
||||||
|
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPodsViewModel(
|
class AirPodsViewModel(
|
||||||
@@ -136,15 +143,40 @@ class AirPodsViewModel(
|
|||||||
_cameraAction.value = action
|
_cameraAction.value = action
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||||
|
require(low in 0..100)
|
||||||
|
require(mid in 0..100)
|
||||||
|
require(high in 0..100)
|
||||||
|
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
|
||||||
|
service.aacpManager.sendCustomEqPacket(updatedEq)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
customEq = updatedEq
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomEqEnabled(enabled: Boolean) {
|
||||||
|
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
observeBroadcasts()
|
observeBroadcasts()
|
||||||
loadName()
|
loadName()
|
||||||
loadInstance()
|
loadInstance()
|
||||||
loadSharedPreferences()
|
loadSharedPreferences()
|
||||||
setupControlObservers()
|
observeAACP()
|
||||||
observeBilling()
|
|
||||||
loadControlList()
|
loadControlList()
|
||||||
|
loadEq()
|
||||||
|
loadATT()
|
||||||
observeATT()
|
observeATT()
|
||||||
|
observeSharedPreferences()
|
||||||
|
observeBilling()
|
||||||
if (isDemoMode) activateDemoMode()
|
if (isDemoMode) activateDemoMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +184,7 @@ class AirPodsViewModel(
|
|||||||
listeners.forEach { (id, listener) ->
|
listeners.forEach { (id, listener) ->
|
||||||
controlRepo.remove(id, listener)
|
controlRepo.remove(id, listener)
|
||||||
}
|
}
|
||||||
|
service.aacpManager.customEqCallback = null
|
||||||
appContext.unregisterReceiver(broadcastReceiver)
|
appContext.unregisterReceiver(broadcastReceiver)
|
||||||
|
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
@@ -172,17 +204,37 @@ class AirPodsViewModel(
|
|||||||
// billingFirstCollectDone = true
|
// billingFirstCollectDone = true
|
||||||
// return@collect
|
// return@collect
|
||||||
// }
|
// }
|
||||||
if (!premium) {
|
if (premium) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||||
|
} else {
|
||||||
|
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||||
setControlCommandBoolean(
|
setControlCommandBoolean(
|
||||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
setHeadGesturesEnabled(false)
|
setHeadGesturesEnabled(false)
|
||||||
}
|
_uiState.update { it.copy(isPremium = false) }
|
||||||
_uiState.update { it.copy(isPremium = premium) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSharedPreferences() {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
when (key) {
|
||||||
|
"name" -> loadName()
|
||||||
|
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
|
||||||
|
"head_gestures", "left_long_press_action", "right_long_press_action",
|
||||||
|
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeBroadcasts() {
|
private fun observeBroadcasts() {
|
||||||
broadcastReceiver = object : BroadcastReceiver() {
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
@@ -289,7 +341,7 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// I'm lazy, sorry.
|
// I'm lazy, sorry.
|
||||||
fun setupControlObservers() {
|
fun observeAACP() {
|
||||||
val identifiersList = listOf(
|
val identifiersList = listOf(
|
||||||
ControlCommandIdentifiers.MIC_MODE,
|
ControlCommandIdentifiers.MIC_MODE,
|
||||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
@@ -321,6 +373,9 @@ class AirPodsViewModel(
|
|||||||
for (identifier in identifiersList) {
|
for (identifier in identifiersList) {
|
||||||
observeControl(identifier)
|
observeControl(identifier)
|
||||||
}
|
}
|
||||||
|
service.aacpManager.customEqCallback = { customEq ->
|
||||||
|
_uiState.update { it.copy(customEq = customEq) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshInitialData() {
|
fun refreshInitialData() {
|
||||||
@@ -328,7 +383,7 @@ class AirPodsViewModel(
|
|||||||
service.let { service ->
|
service.let { service ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLocallyConnected = service.isConnected(), battery = service.getBattery()
|
isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,9 +423,58 @@ class AirPodsViewModel(
|
|||||||
rightAction = rightAction,
|
rightAction = rightAction,
|
||||||
vendorIdHook = vendorIdHook,
|
vendorIdHook = vendorIdHook,
|
||||||
dynamicEndOfCharge = dynamicEndOfCharge,
|
dynamicEndOfCharge = dynamicEndOfCharge,
|
||||||
connectionSuccessful = connectionSuccessful
|
connectionSuccessful = connectionSuccessful,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||||
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
|
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// existing temporary premium
|
||||||
|
expiryTime > 0L -> {
|
||||||
|
if (expiryTime <= now) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = 0L,
|
||||||
|
isPremium = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First migration from accidental FOSS Play build
|
||||||
|
fossUpgraded && !_uiState.value.isPremium -> {
|
||||||
|
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putLong("premium_expiry_time", newExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOffListeningMode(enabled: Boolean) {
|
fun setOffListeningMode(enabled: Boolean) {
|
||||||
@@ -404,6 +508,14 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadEq() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
customEq = service.aacpManager.customEq
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadInstance() {
|
private fun loadInstance() {
|
||||||
val instance = service.airpodsInstance ?: AirPodsInstance(
|
val instance = service.airpodsInstance ?: AirPodsInstance(
|
||||||
name = "AirPods",
|
name = "AirPods",
|
||||||
@@ -454,51 +566,69 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
when (handle) {
|
||||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION -> {
|
||||||
|
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
|
||||||
|
}
|
||||||
|
ATTHandles.HEARING_AID -> {
|
||||||
|
_uiState.value = _uiState.value.copy(hearingAidData = value)
|
||||||
|
}
|
||||||
|
ATTHandles.TRANSPARENCY -> {
|
||||||
|
_uiState.value = _uiState.value.copy(transparencyData = value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
service.attManager?.connect()
|
service.attManager.writeCharacteristic(handle, value)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
|
||||||
delay(250)
|
|
||||||
}
|
|
||||||
service.attManager?.write(handle, value)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshATT() {
|
fun loadATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||||
val loudSoundReduction =
|
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||||
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
loudSoundReduction[0].toInt() == 1
|
||||||
val transparencyData =
|
} else false
|
||||||
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||||
val hearingAid =
|
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||||
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
_uiState.update {
|
||||||
_uiState.value = _uiState.value.copy(
|
it.copy(
|
||||||
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||||
transparencyData = transparencyData,
|
transparencyData = transparencyData,
|
||||||
hearingAidData = hearingAid
|
hearingAidData = hearingAidData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeATT() {
|
fun observeATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
service.attManager?.connect()
|
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||||
delay(1000)
|
// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
}
|
||||||
|
service.attManager.setOnNotificationReceived { handle, value ->
|
||||||
|
when (handle) {
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
|
||||||
|
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
|
||||||
|
value[0].toInt() == 1
|
||||||
|
} else false
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ATTHandles.HEARING_AID.value.toByte() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(hearingAidData = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ATTHandles.TRANSPARENCY.value.toByte() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(transparencyData = value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
refreshATT()
|
|
||||||
delay(15000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -34,7 +35,8 @@ data class AppSettingsUiState(
|
|||||||
val isPremium: Boolean = false,
|
val isPremium: Boolean = false,
|
||||||
val connectionSuccessful: Boolean = false,
|
val connectionSuccessful: Boolean = false,
|
||||||
val showBottomSheetPopup: Boolean = true,
|
val showBottomSheetPopup: Boolean = true,
|
||||||
val showIslandPopup: Boolean = true
|
val showIslandPopup: Boolean = true,
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L
|
||||||
)
|
)
|
||||||
|
|
||||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
@@ -66,12 +68,71 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
|||||||
private fun observeBilling() {
|
private fun observeBilling() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
BillingManager.provider.isPremium.collect { premium ->
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
_uiState.update { it.copy(isPremium = premium) }
|
if (premium) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||||
|
} else {
|
||||||
|
// No billing premium, only update if no temporary premium is active
|
||||||
|
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||||
|
_uiState.update { it.copy(isPremium = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSettings() {
|
private fun loadSettings() {
|
||||||
|
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||||
|
|
||||||
|
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// existing temporary premium
|
||||||
|
expiryTime > 0L -> {
|
||||||
|
if (expiryTime <= now) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = 0L,
|
||||||
|
isPremium = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First migration from accidental FOSS Play build
|
||||||
|
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
|
||||||
|
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putLong("premium_expiry_time", newExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.update { currentState ->
|
_uiState.update { currentState ->
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ import android.util.Log
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@@ -98,7 +99,7 @@ class AirPodsQSService : TileService() {
|
|||||||
Log.d("AirPodsQSService", "onStartListening")
|
Log.d("AirPodsQSService", "onStartListening")
|
||||||
|
|
||||||
val service = ServiceManager.getService()
|
val service = ServiceManager.getService()
|
||||||
isAirPodsConnected = service?.isConnected() == true
|
isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true
|
||||||
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
|
||||||
|
|
||||||
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
|
||||||
import me.kavishdevar.librepods.bluetooth.BLEManager
|
import me.kavishdevar.librepods.bluetooth.BLEManager
|
||||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
@@ -94,6 +96,8 @@ import me.kavishdevar.librepods.data.AirPodsNotifications
|
|||||||
import me.kavishdevar.librepods.data.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.data.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.data.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import me.kavishdevar.librepods.data.isHeadTrackingData
|
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||||
@@ -126,9 +130,9 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
|
|||||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.time.LocalDateTime
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "AirPodsService"
|
private const val TAG = "AirPodsService"
|
||||||
|
|
||||||
@@ -151,7 +155,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
var macAddress = ""
|
var macAddress = ""
|
||||||
var localMac = ""
|
var localMac = ""
|
||||||
lateinit var aacpManager: AACPManager
|
lateinit var aacpManager: AACPManager
|
||||||
var attManager: ATTManager? = null
|
lateinit var attManager: ATTManagerv2
|
||||||
var airpodsInstance: AirPodsInstance? = null
|
var airpodsInstance: AirPodsInstance? = null
|
||||||
var cameraActive = false
|
var cameraActive = false
|
||||||
private var disconnectedBecauseReversed = false
|
private var disconnectedBecauseReversed = false
|
||||||
@@ -231,8 +235,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
lateinit var bleManager: BLEManager
|
lateinit var bleManager: BLEManager
|
||||||
|
|
||||||
private lateinit var socket: BluetoothSocket
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("bluetooth_socket")
|
System.loadLibrary("bluetooth_socket")
|
||||||
@@ -244,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
override fun onDeviceStatusChanged(
|
override fun onDeviceStatusChanged(
|
||||||
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
|
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
|
||||||
) {
|
) {
|
||||||
if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast
|
if (device.connectionState == "Disconnected" && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast
|
||||||
Log.d(TAG, "Seems no device has taken over, we will.")
|
Log.d(TAG, "Seems no device has taken over, we will.")
|
||||||
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
||||||
val bluetoothAdapter = bluetoothManager.adapter
|
val bluetoothAdapter = bluetoothManager.adapter
|
||||||
@@ -256,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
connectToSocket(bluetoothAdapter, bluetoothDevice)
|
connectToSocket(bluetoothAdapter, bluetoothDevice)
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Device status changed")
|
Log.d(TAG, "Device status changed")
|
||||||
if (socket.isConnected) return
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||||
@@ -289,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
|
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
|
||||||
?: "AirPods"
|
?: "AirPods"
|
||||||
)
|
)
|
||||||
if (socket.isConnected) return
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||||
@@ -323,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||||
if (socket.isConnected) return
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||||
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
|
||||||
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
|
||||||
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
|
||||||
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager = AACPManager()
|
aacpManager = AACPManager()
|
||||||
initializeAACPManagerCallback()
|
initializeAACPManagerCallback()
|
||||||
|
|
||||||
|
attManager = ATTManagerv2()
|
||||||
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
localMac = config.selfMacAddress
|
localMac = config.selfMacAddress
|
||||||
@@ -654,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
||||||
|
addAction("android.bluetooth.device.action.UUID")
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionReceiver = object : BroadcastReceiver() {
|
connectionReceiver = object : BroadcastReceiver() {
|
||||||
@@ -691,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
popupShown = false
|
popupShown = false
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
attManager?.disconnect()
|
aacpManager.disconnected()
|
||||||
attManager = null
|
attManager.disconnected()
|
||||||
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
// Store in SharedPreferences
|
// Store in SharedPreferences
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString("airpods_name", deviceInformation.name)
|
putString("name", deviceInformation.name)
|
||||||
putString("airpods_model_number", deviceInformation.modelNumber)
|
putString("airpods_model_number", deviceInformation.modelNumber)
|
||||||
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
||||||
putString("airpods_serial_number", deviceInformation.serialNumber)
|
putString("airpods_serial_number", deviceInformation.serialNumber)
|
||||||
@@ -1094,9 +1100,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
||||||
)
|
)
|
||||||
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
||||||
if (BuildConfig.FLAVOR == "xposed") {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val action = getActionFor(bud, stemPressType)
|
val action = getActionFor(bud, stemPressType)
|
||||||
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
||||||
@@ -1157,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEQPacketReceived(eqData: FloatArray) {
|
override fun onHeadphoneAccommodationReceived(eqData: FloatArray) {
|
||||||
sendBroadcast(
|
sendBroadcast(
|
||||||
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCustomEqReceived(customEq: CustomEq) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesReceived(capabilities: List<Capability>) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUnknownPacketReceived(packet: ByteArray) {
|
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||||
Log.d(
|
Log.d(
|
||||||
"AACPManager",
|
"AACPManager",
|
||||||
@@ -1735,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
val socketFailureChannel = NotificationChannel(
|
val socketFailureChannel = NotificationChannel(
|
||||||
"socket_connection_failure",
|
"socket_connection_failure",
|
||||||
"AirPods Socket Connection Issues",
|
"AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
).apply {
|
).apply {
|
||||||
description = "Notifications about problems connecting to AirPods protocol"
|
description = "Notifications about problems connecting to AirPods protocol"
|
||||||
@@ -1781,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
if (BuildConfig.FLAVOR != "xposed") {
|
if (BuildConfig.FLAVOR != "xposed") {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Not showing socket error notification to user, the service shouldn't be running if it isn't supported."
|
"Not showing BluetoothConnectionManager.getAACPSocket()? error notification to user, the service shouldn't be running if it isn't supported."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.left_charging_icon,
|
R.id.left_charging_icon,
|
||||||
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
||||||
@@ -1921,7 +1933,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.right_charging_icon,
|
R.id.right_charging_icon,
|
||||||
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
||||||
@@ -1932,7 +1944,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.case_charging_icon,
|
R.id.case_charging_icon,
|
||||||
if (caseBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
@@ -2036,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!::socket.isInitialized) {
|
if (BluetoothConnectionManager.getAACPSocket() == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||||
val updatedNotificationBuilder =
|
val updatedNotificationBuilder =
|
||||||
NotificationCompat.Builder(this, "airpods_connection_status")
|
NotificationCompat.Builder(this, "airpods_connection_status")
|
||||||
.setSmallIcon(R.drawable.airpods)
|
.setSmallIcon(R.drawable.airpods)
|
||||||
@@ -2087,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
notificationManager.cancel(1)
|
notificationManager.cancel(1)
|
||||||
} else if (!connected) {
|
} else if (!connected) {
|
||||||
notificationManager.cancel(2)
|
notificationManager.cancel(2)
|
||||||
} else if (!config.bleOnlyMode && !socket.isConnected) {
|
} else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) {
|
||||||
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
|
showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2390,16 +2402,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
?.getString("name", bluetoothDevice?.name)
|
?.getString("name", bluetoothDevice?.name)
|
||||||
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
||||||
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
||||||
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
|
||||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
bluetoothDevice.fetchUuidsWithSdp()
|
|
||||||
if (bluetoothDevice.uuids != null) {
|
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
||||||
if (bluetoothDevice.uuids.contains(uuid)) {
|
if (bluetoothDevice.uuids?.contains(uuid) == true) {
|
||||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||||
intent.putExtra("name", name)
|
intent.putExtra("name", name)
|
||||||
intent.putExtra("device", bluetoothDevice)
|
intent.putExtra("device", bluetoothDevice)
|
||||||
context?.sendBroadcast(intent)
|
context?.sendBroadcast(intent)
|
||||||
|
} else {
|
||||||
|
bluetoothDevice.fetchUuidsWithSdp()
|
||||||
}
|
}
|
||||||
|
} else if ("android.bluetooth.device.action.UUID" == action) {
|
||||||
|
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
?.getString("mac_address", "") ?: ""
|
||||||
|
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
|
||||||
|
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
|
||||||
|
if (matchedByUuid || matchedByMac) {
|
||||||
|
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||||
|
intent.putExtra("name", name)
|
||||||
|
intent.putExtra("device", bluetoothDevice)
|
||||||
|
context?.sendBroadcast(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2452,8 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
Log.d(
|
Log.d(
|
||||||
TAG, "owns connection: $ownsConnection"
|
TAG, "owns connection: $ownsConnection"
|
||||||
)
|
)
|
||||||
if (!::socket.isInitialized) return
|
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
|
||||||
if (socket.isConnected) {
|
|
||||||
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
|
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
|
||||||
Log.d(TAG, "not taking over, vendorid is probably not set to apple")
|
Log.d(TAG, "not taking over, vendorid is probably not set to apple")
|
||||||
return
|
return
|
||||||
@@ -2612,15 +2634,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(
|
private fun createBluetoothSocket(
|
||||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
|
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||||
): BluetoothSocket {
|
): BluetoothSocket {
|
||||||
val type = 3 // L2CAP
|
val type = 3 // L2CAP
|
||||||
val constructorSpecs = listOf(
|
val constructorSpecs = listOf(
|
||||||
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
|
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||||
arrayOf(device, type, true, true, 0x1001, uuid),
|
arrayOf(device, type, true, true, psm, uuid),
|
||||||
arrayOf(device, type, 1, true, true, 0x1001, uuid),
|
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||||
arrayOf(type, 1, true, true, device, 0x1001, uuid),
|
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||||
arrayOf(type, true, true, device, 0x1001, uuid)
|
arrayOf(type, true, true, device, psm, uuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||||
@@ -2662,11 +2684,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
fun connectToSocket(
|
fun connectToSocket(
|
||||||
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
|
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
if (BluetoothConnectionManager.getAACPSocket() != null && BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return
|
||||||
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
|
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
|
||||||
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
// if (!isConnectedLocally) {
|
// if (!isConnectedLocally) {
|
||||||
socket = try {
|
val socket = try {
|
||||||
createBluetoothSocket(adapter, device, uuid)
|
createBluetoothSocket(adapter, device, uuid, 4097)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
||||||
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
||||||
@@ -2675,17 +2698,30 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000L) {
|
withTimeout(5000.milliseconds) {
|
||||||
try {
|
try {
|
||||||
socket.connect()
|
socket.connect()
|
||||||
// isConnectedLocally = true
|
// isConnectedLocally = true
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
|
|
||||||
BluetoothConnectionManager.setCurrentConnection(socket, device)
|
|
||||||
val xposedRemotePref = XposedRemotePrefProvider.create()
|
val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
||||||
attManager = ATTManager(adapter, device)
|
createBluetoothSocket(
|
||||||
attManager!!.connect()
|
adapter,
|
||||||
|
device,
|
||||||
|
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
|
||||||
|
31
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
attSocket?.connect()
|
||||||
|
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
|
||||||
|
if (attSocket != null) {
|
||||||
|
attManager.startReader()
|
||||||
|
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
|
||||||
|
attManager.readCharacteristic(ATTHandles.HEARING_AID)
|
||||||
|
attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||||
|
// attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create AirPodsInstance from stored config if available
|
// Create AirPodsInstance from stored config if available
|
||||||
@@ -2740,7 +2776,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!socket.isConnected) {
|
if (!socket.isConnected) {
|
||||||
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
|
Log.d(TAG, "<LogCollector:Complete:Failed> socket not connected")
|
||||||
if (manual) {
|
if (manual) {
|
||||||
sendToast(
|
sendToast(
|
||||||
"Couldn't connect to socket: timeout."
|
"Couldn't connect to socket: timeout."
|
||||||
@@ -2751,13 +2787,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
socket.let {
|
BluetoothConnectionManager.getAACPSocket()?.let {
|
||||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||||
aacpManager.sendSetFeatureFlagsPacket()
|
aacpManager.sendSetFeatureFlagsPacket()
|
||||||
aacpManager.sendNotificationRequest()
|
aacpManager.sendNotificationRequest()
|
||||||
Log.d(TAG, "Requesting proximity keys")
|
Log.d(TAG, "Requesting proximity keys")
|
||||||
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(200)
|
||||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||||
delay(200)
|
delay(200)
|
||||||
aacpManager.sendSetFeatureFlagsPacket()
|
aacpManager.sendSetFeatureFlagsPacket()
|
||||||
@@ -2785,7 +2822,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
setupStemActions()
|
setupStemActions()
|
||||||
|
|
||||||
while (socket.isConnected) {
|
while (socket.isConnected) {
|
||||||
socket.let { it ->
|
|
||||||
try {
|
try {
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
val bytesRead = it.inputStream.read(buffer)
|
val bytesRead = it.inputStream.read(buffer)
|
||||||
@@ -2813,7 +2849,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else if (bytesRead == -1) {
|
} else if (bytesRead == -1) {
|
||||||
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
|
Log.d("AirPodsService", "socket closed (bytesRead = -1)")
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
})
|
})
|
||||||
@@ -2829,11 +2865,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
Log.d("AirPods Service", "socket closed")
|
||||||
Log.d("AirPods Service", "Socket closed")
|
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
socket.close()
|
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
@@ -2843,20 +2878,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Log.d(TAG, "Failed to connect to socket: ${e.message}")
|
Log.d(TAG, "Failed to connect to BluetoothConnectionManager.getAACPSocket()?: ${e.message}")
|
||||||
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
|
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
}
|
}
|
||||||
// } else {
|
// } else {
|
||||||
// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
|
// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.getAACPSocket()? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.getAACPSocket()?.isConnected = ${this::BluetoothConnectionManager.getAACPSocket()?.isInitialized && BluetoothConnectionManager.getAACPSocket()?.isConnected})")
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectForCD() {
|
fun disconnectForCD() {
|
||||||
if (!this::socket.isInitialized) return
|
BluetoothConnectionManager.getAACPSocket()?.close()
|
||||||
socket.close()
|
|
||||||
MediaController.pausedWhileTakingOver = false
|
MediaController.pausedWhileTakingOver = false
|
||||||
Log.d(TAG, "Disconnected from AirPods, showing island.")
|
Log.d(TAG, "Disconnected from AirPods, showing island.")
|
||||||
showIsland(
|
showIsland(
|
||||||
@@ -2887,11 +2921,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectAirPods() {
|
fun disconnectAirPods() {
|
||||||
if (!this::socket.isInitialized) return
|
if (BluetoothConnectionManager.getAACPSocket() == null) return
|
||||||
socket.close()
|
try {
|
||||||
|
BluetoothConnectionManager.getAACPSocket()?.close()
|
||||||
|
} catch(e: Exception) {
|
||||||
|
Log.e(TAG, "error closing aacp socket ${e.message}")
|
||||||
|
}
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
attManager?.disconnect()
|
attManager.disconnected()
|
||||||
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
@@ -3199,10 +3238,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isConnected(): Boolean {
|
|
||||||
return if (::socket.isInitialized) socket.isConnected else false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int {
|
private fun Int.dpToPx(): Int {
|
||||||
|
|||||||
@@ -22,22 +22,13 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
|
||||||
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT == 37) return true
|
||||||
|
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
|
||||||
|
if (isBypassFlagActive) return true
|
||||||
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
||||||
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
|
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
|
||||||
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
|
if (isPixel && Build.VERSION.SDK_INT == 36) {
|
||||||
|
|
||||||
if (isBypassFlagActive) return true
|
|
||||||
|
|
||||||
if (isPixel) {
|
|
||||||
when (Build.VERSION.SDK_INT) {
|
|
||||||
36 -> {
|
|
||||||
return Build.ID.startsWith("CP1A")
|
return Build.ID.startsWith("CP1A")
|
||||||
}
|
|
||||||
|
|
||||||
37 -> {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isOppoFamily) {
|
} else if (isOppoFamily) {
|
||||||
return Build.VERSION.SDK_INT >= 36
|
return Build.VERSION.SDK_INT >= 36
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,4 +276,7 @@
|
|||||||
<string name="optimized_charging">Optimized Charge Limit</string>
|
<string name="optimized_charging">Optimized Charge Limit</string>
|
||||||
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
|
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
|
||||||
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string>
|
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string>
|
||||||
|
<string name="play_foss_premium_banner">Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience.</string>
|
||||||
|
<string name="custom">Custom</string>
|
||||||
|
<string name="recommended">Recommended</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
BIN
imgs/banner-dark.png
Normal file
BIN
imgs/banner-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
@@ -1,3 +1,26 @@
|
|||||||
|
# LibrePods on Linux
|
||||||
|
|
||||||
|
A new rewrite is being worked upon. Please look at the list of features in the root README to see what's supported in the new version.
|
||||||
|
|
||||||
|
The rewrite can be found in the `linux/rust` branch [here](https://github.com/kavishdevar/librepods/tree/linux/rust/linux-rust). Follow the development in [PR #241](https://github.com/kavishdevar/librepods/pull/241).
|
||||||
|
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### GitHub Releases
|
||||||
|
|
||||||
|
The app is ready to download as an AppImage or an executable. You can download the latest pre-release from the [GitHub releases](https://github.com/kavishdevar/librepods/releases?q="linux-v0").
|
||||||
|
|
||||||
|
### Nightly Builds (recommended)
|
||||||
|
|
||||||
|
You can also try the latest build of the new version from the [GitHub Actions artifacts](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml). On the latest successful workflow run, download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**.
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>README for the old version</summary>
|
||||||
|
|
||||||
# LibrePods Linux
|
# LibrePods Linux
|
||||||
|
|
||||||

|

|
||||||
@@ -189,3 +212,5 @@ It is possible that the AirPods disconnect after a short period of time and play
|
|||||||
### Why a separate script?
|
### Why a separate script?
|
||||||
|
|
||||||
Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application.
|
Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application.
|
||||||
|
|
||||||
|
</details>
|
||||||
Reference in New Issue
Block a user