Compare commits

..

25 Commits

Author SHA1 Message Date
Kavish Devar
bf6a3cac4a android: add back troubleshooter for non-Play builds 2026-04-23 00:47:09 +05:30
Kavish Devar
fce90cf88a android: add logs in xposed module 2026-04-23 00:46:06 +05:30
Kavish Devar
3346221569 android: remove camera control
accessibility service kept on disabling automatically
2026-04-23 00:08:01 +05:30
Kavish Devar
9355a0cef1 android: something 2026-04-23 00:08:00 +05:30
Kavish Devar
45915ca560 android: add demo mode; fix issues on UI start 2026-04-23 00:07:59 +05:30
Kavish Devar
78920ef486 android: remove backdrop libs 2026-04-23 00:07:59 +05:30
Kavish Devar
7d58fb502a android: 'testing' for Play relase
yeah... no big changes, unfortunately
2026-04-23 00:07:55 +05:30
Kavish Devar
e2308387fa android: read listening mode status from service when picker is composed 2026-04-23 00:07:55 +05:30
Kavish Devar
e3b1f4f93b android: remove duplicate assets & add stem controls for airpods4anc 2026-04-23 00:07:54 +05:30
Kavish Devar
08484e055d android: remove reset offsets button
no longer needed, offset is now found in the xposed module itself
2026-04-23 00:07:54 +05:30
Kavish Devar
05da27331f android: remove QS dialog mode
problems with scaling currently; it will be worked upon later
2026-04-23 00:07:54 +05:30
Kavish Devar
0087b871a1 android: assume off listening mode is available when the control command is not available 2026-04-23 00:07:54 +05:30
Kavish Devar
8469ce4984 android: parse optimized charging status
thanks @d4rken
2026-04-23 00:07:53 +05:30
Kavish Devar
a24a8986e1 android: remove duplicate assets
will add actual ones later
2026-04-23 00:07:53 +05:30
Kavish Devar
28c5510417 remove unused hook 2026-04-23 00:07:53 +05:30
Kavish Devar
2cd35a7e77 remove dependency on hiddenapibypass 2026-04-23 00:07:52 +05:30
Kavish Devar
4cff153402 disable DI hook and dependant functions 2026-04-23 00:07:52 +05:30
Kavish Devar
01e43a8e29 add A16QPR3 constructor for ATT
i really need to clean this up
2026-04-23 00:07:52 +05:30
Kavish Devar
e8b0e4538b actually bring back DI hook 2026-04-23 00:07:51 +05:30
Kavish Devar
c7778e15be bump version 2026-04-23 00:07:51 +05:30
Kavish Devar
6ccd068da8 add A16QPR3 constructor
it's almost as if google is doing this deliberately doing this
2026-04-23 00:07:51 +05:30
Kavish Devar
9893181498 add back DI hook 2026-04-23 00:07:51 +05:30
Kavish Devar
61ff89e114 android: disable DI hook
for CI
2026-04-23 00:07:50 +05:30
Kavish Devar
d9f7e84daa remove radare2 onboarding
mostly ai generated, but I've tested it.
2026-04-23 00:07:50 +05:30
Kavish Devar
c9abe2a167 call original fcr_chk_chan to set preferred mode to BASIC 2026-04-23 00:07:47 +05:30
119 changed files with 4687 additions and 5246 deletions

View File

@@ -1,110 +0,0 @@
name: Bug report (Android)
description: Report a bug in the Android app
labels: ["bug", "android"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill in as much as you can.
- type: input
id: app-version
attributes:
label: App version
description: "Find this in `Settings → About → Version` in the app, or in your phone's app info."
placeholder: "v0.2.5 (build 46)"
validations:
required: true
- type: dropdown
id: app-source
attributes:
label: App source
options:
- GitHub
- Play
- Built from source
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: Manufacturer and model.
placeholder: "Google Pixel 8 Pro"
validations:
required: true
- type: input
id: android-version
attributes:
label: Android / OS version
description: Include the OEM skin if relevant.
placeholder: "Android 16, OxygenOS 16, ColorOS 16, ..."
validations:
required: true
- type: dropdown
id: root-method
attributes:
label: Root / hook method
options:
- No root (native L2CAP support)
- Magisk + Xposed
- KernelSU + Xposed
- Other (describe in additional context)
validations:
required: true
- type: dropdown
id: airpods-model
attributes:
label: AirPods model
options:
- AirPods (1st gen)
- AirPods (2nd gen)
- AirPods (3rd gen)
- AirPods (4th gen)
- AirPods (4th gen) with ANC
- AirPods Pro (1st gen)
- AirPods Pro 2 (Lightning)
- AirPods Pro 2 (USB-C)
- AirPods Pro 3
- Other / not sure
validations:
required: true
- type: input
id: firmware
attributes:
label: AirPods firmware
description: Find this under `About` in the app once connected.
placeholder: "8454768"
- type: textarea
id: description
attributes:
label: What happened
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
If you are rooted, give the app root access, open the app, go to `Settings → Troubleshooting → Collect Logs`, and attach the resulting file here.
Without logs most bugs are very hard to diagnose. If you are not, follow these instructions:
(Needs access to a computer, and USB/Wireless Debugging under developer options enabled)
Commands:
- Get the uid: Linux/Mac: `adb shell dumpsys package me.kavishdevar.librepods | grep uid`
- Start logs: `adb logcat --uid=<uid>,1002 >> librepods-logs.txt` (1002 is for bluetooth)
Steps for proper logs
- force close the app
- turn off bluetooth
- start logs
- open the app
- turn on bluetooth and connect
placeholder: Paste log content or attach the file
- type: textarea
id: extra
attributes:
label: Additional context
description: Anything else that might help (screenshots, video, related issues, what you've already tried).

View File

@@ -1,83 +0,0 @@
name: Bug report (Linux)
description: Report a bug in the Linux program
labels: ["bug", "linux"]
title: "[Linux] "
body:
- type: markdown
attributes:
value: |
Thanks for the report. Please fill in as much as you can.
- type: input
id: app-version
attributes:
label: App version
placeholder: "linux-v0.1.0, or linux-rust commit abc1234"
validations:
required: true
- type: dropdown
id: variant
attributes:
label: Variant
options:
- Rust rewrite (`linux-rust` branch)
- QT version (NOT MAINTAINED! issues will be closed)
validations:
required: true
- type: input
id: distro
attributes:
label: Distro and version
placeholder: "Arch Linux, Fedora 41, Ubuntu 24.04, NixOS 25.05"
validations:
required: true
- type: input
id: desktop
attributes:
label: Desktop environment / compositor
placeholder: "GNOME 47 (Wayland), KDE 6 (X11), Hyprland, ..."
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install method (only official sources)
options:
- Built from source (`nix` or otherwise)
- Pre-built binary
- AppImage
- Other
validations:
required: true
- type: dropdown
id: airpods-model
attributes:
label: AirPods model
options:
- AirPods (1st gen)
- AirPods (2nd gen)
- AirPods (3rd gen)
- AirPods (4th gen)
- AirPods (4th gen) with ANC
- AirPods Pro (1st gen)
- AirPods Pro 2 (Lightning)
- AirPods Pro 2 (USB-C)
- AirPods Pro 3
- Other / not sure
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and stderr
description: Run the app from a terminal with `--debug` and paste the output.
- type: textarea
id: extra
attributes:
label: Additional context

View File

@@ -1,31 +0,0 @@
name: Feature request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: dropdown
id: scope
attributes:
label: Scope
options:
- Android
- Linux
- Both
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What are you trying to do? What is missing or hard today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How might it work? UI sketches, behavior, edge cases.
- type: textarea
id: alternatives
attributes:
label: Alternatives considered

View File

@@ -1,212 +1,100 @@
name: Android CI
name: Build APK and root module (and create nightly release)
on:
push:
branches:
- '*'
paths:
- 'android/**'
- 'root-module-manual/**'
pull_request:
paths:
- 'android/**'
workflow_dispatch:
inputs:
branch:
description: Branch to build
release:
description: 'Create a nightly release'
required: true
default: main
type: boolean
default: false
custom_notes:
description: 'Custom updates to add to What''s Changed section'
required: false
type: string
workflow_call:
jobs:
build:
build-debug-apk:
runs-on: ubuntu-latest
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
app_version: ${{ steps.version.outputs.app_version }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
- uses: actions/setup-java@v4
with:
distribution: zulu
distribution: 'zulu'
java-version: 21
- uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
if: github.event_name != 'pull_request'
run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept Licenses
run: yes | sdkmanager --licenses
- name: Install NDK
run: sdkmanager "ndk;30.0.14904198"
- name: Create local.properties
if: github.event_name != 'pull_request'
run: |
cat <<EOF > android/local.properties
RELEASE_STORE_FILE=../release.keystore
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
EOF
- name: Build debug APK for PRs
if: github.event_name == 'pull_request'
run: ./gradlew assembleFossDebug
- name: Build debug APK
run: ./gradlew assembleDebug
working-directory: android
- name: Build release artifacts
if: github.event_name != 'pull_request'
run: ./gradlew packageReleaseArtifacts
working-directory: android
- name: Get app version
id: version
run: echo "app_version=$(grep 'appVersionName =' android/app/build.gradle.kts | sed 's/.*= "\(.*\)"/\1/')" >> $GITHUB_OUTPUT
- id: vars
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: apk-release
path: release/*release.apk
- uses: actions/upload-artifact@v4
if: github.event_name == 'pull_request'
with:
name: apk-debug
path: android/app/build/outputs/apk/foss/debug/app-foss-debug.apk
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: apk-debug
path: release/*debug.apk
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: root-module-release
path: release/*release.zip
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: root-module-debug
path: release/*debug.zip
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: release-bundle
path: release/*.aab
release:
if: github.event_name == 'push'
name: Debug APK
path: android/app/build/outputs/apk/**/*.apk
nightly-release:
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true'
needs: build-debug-apk
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4
with:
name: apk-release
path: artifacts/apk-release
- uses: actions/download-artifact@v4
with:
name: apk-debug
path: artifacts/apk-debug
- uses: actions/download-artifact@v4
with:
name: root-module-release
path: artifacts/root-module-release
- uses: actions/download-artifact@v4
with:
name: root-module-debug
path: artifacts/root-module-debug
- id: prev
- name: Export APK_NAME for later use
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
- name: Rename .apk file
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
- name: Decode keystore file
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
- name: Install apksigner
run: sudo apt-get update && sudo apt-get install -y apksigner
- name: Sign APK
run: |
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
- name: Verify APK
run: apksigner verify "./$APK_NAME"
- name: Fetch the latest non-nightly release tag
id: fetch-tag
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
- name: Retrieve commits since the last release
id: get-commits
run: |
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
echo "::set-output name=commits::${COMMITS}"
- name: Prepare release notes
id: release-notes
run: |
# Create a temporary file for release notes
NOTES_FILE=$(mktemp)
# Process custom notes if they exist
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
# Check if custom notes already have bullet points or GitHub-style formatting
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
# Already formatted, use as is
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
else
# Add bullet point formatting
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
fi
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
- name: Zip root-module directory
run: sh ./build-magisk-module.sh
- name: Delete release if exist then create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG=$(gh release list \
--limit 1 \
--json tagName \
--jq '.[0].tagName')
echo "tag=$TAG" >> $GITHUB_OUTPUT
- id: changelog
run: |
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
else
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
fi
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- id: tag
run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.tag.outputs.tag }}" \
artifacts/**/* \
-t "Nightly ${{ needs.build.outputs.short_sha }}" \
--notes "${{ steps.changelog.outputs.notes }}" \
--prerelease
- name: Get timestamp
id: timestamp
run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
- name: Post to Discord
run: |
curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \
-H "Content-Type: application/json" \
-d '{
"embeds": [
{
"title": "LibrePods Nightly Build",
"description": "Download the latest debug and release APKs.",
"color": 253060,
"fields": [
{
"name": "Changelog",
"value": "${{ steps.changelog.outputs.notes }}",
"inline": false
}
],
"timestamp": "${{ steps.timestamp.outputs.timestamp }}",
"footer": {
"text": "GitHub Actions"
}
}
],
"components": [
{
"type": 1,
"components": [
{
"type": 2,
"label": "Download Release APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk"
},
{
"type": 2,
"label": "Download Debug APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk"
}
]
}
]
}'
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes

View File

@@ -1,4 +1,4 @@
name: Linux CI (Rust)
name: Linux Build & Release
on:
push:

7
.gitignore vendored
View File

@@ -1,5 +1,10 @@
release
root-module/radare2-5.9.9-android-aarch64.tar.gz
wak.toml
log.txt
btl2capfix.zip
root-module-manual
.vscode
testing.py
.DS_Store
CMakeLists.txt.user*

View File

@@ -0,0 +1,164 @@
# Bluetooth Low Energy (BLE) - Apple Proximity Pairing Message
This document describes how the AirPods BLE "Proximity Pairing Message" is parsed and interpreted in the application. This message is broadcast by Apple devices (such as AirPods) and contains key information about the device's state, battery, and other properties.
## Overview
When scanning for BLE devices, the application looks for manufacturer data with Apple's ID (`0x004C`). If the data starts with `0x07`, it is identified as a Proximity Pairing Message. The message contains various fields, each representing a specific property of the AirPods.
## Proximity Pairing Message Structure
| Byte Index | Field Name | Description | Example Value(s) |
|------------|-------------------------|---------------------------------------------------------|--------------------------|
| 0 | Prefix | Message type (should be `0x07` for proximity pairing) | `0x07` |
| 1 | Length | Length of the message | `0x12` |
| 2 | Pairing Mode | `0x01` = Paired, `0x00` = Pairing mode | `0x01`, `0x00` |
| 3-4 | Device Model | Big-endian: [3]=high, [4]=low | `0x0E20` (AirPods Pro) |
| 5 | Status | Bitfield, see below | `0x62` |
| 6 | Pods Battery Byte | Nibbles for left/right pod battery | `0xA7` |
| 7 | Flags & Case Battery | Upper nibble: case battery, lower: flags | `0xB3` |
| 8 | Lid Indicator | Bits for lid state and open counter | `0x09` |
| 9 | Device Color | Color code | `0x02` |
| 10 | Connection State | Enum, see below | `0x04` |
| 11-26 | Encrypted Payload | 16 bytes, not parsed | |
## Field Details
### Device Model
| Value (hex) | Model Name |
|-------------|--------------------------|
| 0x0220 | AirPods 1st Gen |
| 0x0F20 | AirPods 2nd Gen |
| 0x1320 | AirPods 3rd Gen |
| 0x1920 | AirPods 4th Gen |
| 0x1B20 | AirPods 4th Gen (ANC) |
| 0x0A20 | AirPods Max |
| 0x1F20 | AirPods Max (USB-C) |
| 0x0E20 | AirPods Pro |
| 0x1420 | AirPods Pro 2nd Gen |
| 0x2420 | AirPods Pro 2nd Gen (USB-C) |
### Status Byte (Bitfield)
| Bit | Meaning | Value if Set |
|-----|--------------------------------|-------------|
| 0 | Right Pod In Ear (XOR logic) | true |
| 1 | Right Pod In Ear (XOR logic) | true |
| 2 | Both Pods In Case | true |
| 3 | Left Pod In Ear (XOR logic) | true |
| 4 | One Pod In Case | true |
| 5 | Primary Pod (1=Left, 0=Right) | true/false |
| 6 | This Pod In Case | true |
### Ear Detection Logic
The in-ear detection uses XOR logic based on:
- Whether the right pod is primary (`areValuesFlipped`)
- Whether this pod is in the case (`isThisPodInTheCase`)
```cpp
bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
```
### Primary Pod
Determined by bit 5 of the status byte:
- `1` = Left pod is primary
- `0` = Right pod is primary
This affects:
1. Battery level interpretation (which nibble corresponds to which pod)
2. Microphone assignment
3. Ear detection logic
### Microphone Status
The active microphone is determined by:
```cpp
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
```
### Pods Battery Byte
- Upper nibble: one pod battery (depends on primary)
- Lower nibble: other pod battery
| Value | Meaning |
|-------|----------------|
| 0x0-0x9 | 0-90% (x10) |
| 0xA-0xE | 100% |
| 0xF | Not available|
### Flags & Case Battery Byte
- Upper nibble: case battery (same encoding as pods)
- Lower nibble: flags
#### Flags (Lower Nibble)
| Bit | Meaning |
|-----|--------------------------|
| 0 | Right Pod Charging (XOR) |
| 1 | Left Pod Charging (XOR) |
| 2 | Case Charging |
### Lid Indicator
| Bits | Meaning |
|------|------------------------|
| 0-2 | Lid Open Counter |
| 3 | Lid State (0=Open, 1=Closed) |
### Device Color
| Value | Color |
|-------|-------------|
| 0x00 | White |
| 0x01 | Black |
| 0x02 | Red |
| 0x03 | Blue |
| 0x04 | Pink |
| 0x05 | Gray |
| 0x06 | Silver |
| 0x07 | Gold |
| 0x08 | Rose Gold |
| 0x09 | Space Gray |
| 0x0A | Dark Blue |
| 0x0B | Light Blue |
| 0x0C | Yellow |
| 0x0D+ | Unknown |
### Connection State
| Value | State |
|-------|--------------|
| 0x00 | Disconnected |
| 0x04 | Idle |
| 0x05 | Music |
| 0x06 | Call |
| 0x07 | Ringing |
| 0x09 | Hanging Up |
| 0xFF | Unknown |
## Example Message
| Byte Index | Example Value | Description |
|------------|--------------|----------------------------|
| 0 | 0x07 | Proximity Pairing Message |
| 1 | 0x12 | Length |
| 2 | 0x01 | Paired |
| 3-4 | 0x0E 0x20 | AirPods Pro |
| 5 | 0x62 | Status |
| 6 | 0xA7 | Pods Battery |
| 7 | 0xB3 | Flags & Case Battery |
| 8 | 0x09 | Lid Indicator |
| 9 | 0x02 | Device Color |
| 10 | 0x04 | Connection State (Idle) |
---
For further details, see [`BleManager`](linux/ble/blemanager.cpp) and [`BleScanner`](linux/ble/blescanner.cpp).

278
README.md
View File

@@ -1,99 +1,101 @@
<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]
Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
Development paused due to lack of time until 17th May 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>
![LibrePods Banner](./imgs/banner.png)
# What is LibrePods?
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.
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.
# Feature availability
# Device Compatibility
| Feature | Linux | Android |
| ----------------------------------------------------------- | ----- | ------- |
| Changing Listening Mode | ✅ | ✅ |
| Ear detection | ✅ | ✅ |
| Battery status | ✅ | ✅ |
| 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) | 🔴 | 🔴 |
| Status | Device | Features |
| ------ | --------------------- | ---------------------------------------------------------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
| 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 |
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).
## Find My
# Key Features
The following features related to Find My are planned, but require further RE and might need root on Android:
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
- **Battery Status**: Accurate battery levels
- **Head Gestures**: Answer calls just by nodding your head
- **Conversational Awareness**: Volume automatically lowers when you speak
- **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!
- Add your AirPods to the Find My network
- Play sound through charging case to find it
- Notify when leaving behind
- Toggle case charging sounds
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
## Spatial Audio
# Platform Support
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.
## Linux
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
## 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.
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
## High quality two-way audio
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
## Android
Since this needs deeper integration with audio on Android, it will most likely need root.
### Screenshots
# Installation
| | | |
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
| ![Settings 1](./android/imgs/settings-1.png) | ![Settings 2](./android/imgs/settings-2.png) | ![Debug Screen](./android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](./android/imgs/notification-and-qs.png) | ![Popup](./android/imgs/popup.png) | ![Head Tracking and Gestures](./android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./android/imgs/long-press.png) | ![Widget](./android/imgs/widget.png) | ![Customizations 1](./android/imgs/customizations-1.png) |
| ![Customizations 2](./android/imgs/customizations-2.png) | ![accessibility](./android/imgs/accessibility.png) | ![transparency](./android/imgs/transparency.png) |
| ![hearing-aid](./android/imgs/hearing-aid.png) | ![hearing-test](./android/imgs/hearing-test.png) | ![hearing-aid-adjustments](./android/imgs/hearing-aid-adjustments.png) |
- [**Android**](/android/README.md)
- [**Linux**](/linux/README.md)
# VendorID Spoofing
here's a very unprofessional demo video
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Root Requirement
If you are using ColorOS/OxygenOS 16, Android 16 QPR3, Android 17 Beta 3 or higher, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root.
For everyone else:
**You must have a rooted device with Xposed to use LibrePods on Android.**
### 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!
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).
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
## Multi-device Connectivity
@@ -105,123 +107,49 @@ 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.
# 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
A huge thank you to everyone supporting the project!
- @davdroman
- @tedsalmon
- @wiless
- @SmartMsg
- @lunaroyster
- @ressiwage
<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
# Special thanks
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
- @devnoname120 for helping with the first root patch
- @timgromeyer for making the first version of the linux app
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
- 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:
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
# Nightly / Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
### Android
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
2. Click on the most recent successful workflow run
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
4. Extract the zip and install the `.apk` on your device
> [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update — you may need to uninstall the stable version first.
### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
2. Click on the most recent successful workflow run
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
> [!WARNING]
> Nightly builds are unstable and may contain bugs. Use at your own risk.
# Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">

View File

@@ -1,67 +0,0 @@
## 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
| | | |
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
| ![Settings 1](./imgs/settings-1.png) | ![Settings 2](./imgs/settings-2.png) | ![Head Tracking and Gestures](./imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./imgs/long-press.png) | ![Customizations 1](./imgs/customizations-1.png) | ![accessibility](./imgs/accessibility.png) |
| ![transparency](./imgs/transparency.png) | ![hearing-aid](./imgs/hearing-aid.png) | ![hearing-test](./imgs/hearing-test.png) |
| ![hearing-aid-adjustments](./imgs/hearing-aid-adjustments.png) | ![Battery Notification and QS Tile for NC Mode](./imgs/notification-and-qs.png) | ![Widget](./imgs/widget.png) |
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.

View File

@@ -1,7 +1,5 @@
import java.util.Properties
val appVersionName = "0.3.0"
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
@@ -10,29 +8,17 @@ plugins {
id("kotlin-parcelize")
}
val localPropsFile = rootProject.file("local.properties")
val props = Properties().apply {
if (localPropsFile.exists()) {
load(localPropsFile.inputStream())
}
load(rootProject.file("local.properties").inputStream())
}
val releaseSigningAvailable = listOf(
"RELEASE_STORE_FILE",
"RELEASE_STORE_PASSWORD",
"RELEASE_KEY_ALIAS",
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
android {
signingConfigs {
if (releaseSigningAvailable) {
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
keyAlias = props["RELEASE_KEY_ALIAS"] as String
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
keyAlias = props["RELEASE_KEY_ALIAS"] as String
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
}
namespace = "me.kavishdevar.librepods"
@@ -40,49 +26,38 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
minSdk = 33
targetSdk = 37
versionCode = 56
versionName = appVersionName
versionCode = 28
versionName = "0.2.0"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
externalNativeBuild {
cmake {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}
defaultConfig {
minSdk = 33
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
}
debug {
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}
versionNameSuffix = "-debug"
defaultConfig {
minSdk = 33
}
}
}
productFlavors {
create("foss") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
}
create("play") {
dimension = "env"
create("playRelease") {
initWith(getByName("release"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
minSdk = 36
}
}
compileOptions {
@@ -105,20 +80,39 @@ android {
}
sourceSets {
getByName("main") {
res.directories += "src/main/res-apple"
res.directories+="src/main/res-apple"
}
}
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
productFlavors {
create("normal") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=OFF"
}
}
}
create("xposed") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=ON"
}
}
versionNameSuffix = "-xposed"
}
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui)
@@ -141,92 +135,15 @@ dependencies {
implementation(libs.backdrop)
// implementation(libs.hilt)
// implementation(libs.hilt.compiler)
compileOnly(libs.libxposed.api)
implementation(libs.libxposed.service)
implementation(libs.play.review)
implementation(libs.play.review.ktx)
add("xposedCompileOnly", libs.libxposed.api)
add("xposedImplementation", libs.libxposed.service)
add("playReleaseImplementation", libs.billing)
}
aboutLibraries {
export {
export{
prettyPrint = true
excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json")
}
}
val rootModuleDir = rootProject.file("../root-module-manual")
val releaseDir = rootProject.file("../release")
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
fun registerRootModuleZipTask(
name: String,
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
from(rootModuleDir)
duplicatesStrategy = DuplicatesStrategy.WARN
from(layout.buildDirectory.file(apkPath)) {
into("system/priv-app/LibrePods")
rename { "LibrePods.apk" }
}
delete(layout.buildDirectory.dir("outputs/rootModuleZips"))
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
}
val zipRelease = registerRootModuleZipTask(
"zipReleaseModule",
"foss",
"release"
)
val zipDebug = registerRootModuleZipTask(
"zipDebugModule",
"foss",
"debug"
)
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn(
zipRelease,
zipDebug,
"bundlePlayRelease"
)
into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
}
from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
}
from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
include("*.aab")
}
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
include("*.zip")
}
}
tasks.register("packageReleaseArtifacts") {
dependsOn(collect)
}

Binary file not shown.

View File

@@ -20,4 +20,5 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**

View File

@@ -14,18 +14,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission
android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<!-- <uses-permission-->
<!-- android:name="android.permission.BLUETOOTH_PRIVILEGED"-->
<!-- tools:ignore="ProtectedPermissions" />-->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
@@ -36,6 +27,8 @@
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->

View File

@@ -3,6 +3,8 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
option(IS_XPOSED "Build Xposed components" OFF)
add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
)
@@ -22,36 +24,40 @@ target_link_libraries(bluetooth_socket
log
)
if(IS_XPOSED)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
add_library(l2c_fcr_hook SHARED
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
xz/xz_crc32.c
xz/xz_crc64.c
xz/xz_sha256.c
xz/xz_dec_stream.c
xz/xz_dec_lzma2.c
xz/xz_dec_bcj.c
)
${XPOSED_SRC_DIR}/xz/xz_crc32.c
${XPOSED_SRC_DIR}/xz/xz_crc64.c
${XPOSED_SRC_DIR}/xz/xz_sha256.c
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
xz
)
target_include_directories(l2c_fcr_hook PRIVATE
${XPOSED_SRC_DIR}
${XPOSED_SRC_DIR}/xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_link_libraries(l2c_fcr_hook
android
log
)
target_link_libraries(l2c_fcr_hook
android
log
)
endif()

View File

@@ -1,41 +0,0 @@
package me.kavishdevar.librepods
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
override fun onCreate() {
XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
XposedState.isAvailable = XposedServiceHolder.service != null
XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true
}
override fun onServiceBind(service: XposedService) {
XposedServiceHolder.service = service
XposedState.isAvailable = true
XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true
}
override fun onServiceDied(p0: XposedService) {
XposedServiceHolder.service = null
XposedState.isAvailable = false
}
}

View File

@@ -24,7 +24,6 @@ package me.kavishdevar.librepods
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
@@ -53,6 +52,7 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -64,6 +64,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -80,6 +81,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -91,6 +94,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
@@ -112,17 +117,17 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.play.core.review.ReviewManagerFactory
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
@@ -130,7 +135,6 @@ import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
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.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
@@ -143,25 +147,22 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.isSupported
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
companion object {
init {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
if (BuildConfig.FLAVOR == "xposed") {
System.loadLibrary("l2c_fcr_hook")
}
}
@@ -173,7 +174,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
LibrePodsTheme {
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme {
Main()
}
}
@@ -220,87 +221,143 @@ class MainActivity : ComponentActivity() {
fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) }
val blockTouches = remember { mutableStateOf(false) }
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val scrollState = rememberScrollState()
LaunchedEffect(blockTouches) {
if (blockTouches.value) {
delay(500)
blockTouches.value = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Column(
Box (
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement
.spacedBy(16.dp)
.fillMaxSize()
.then(
if (blockTouches.value)
{
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
}
else Modifier
)
)
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
Column(
modifier = Modifier,
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.not_supported),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = textColor,
fontSize = 28.sp,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Not supported",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 20.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row (
modifier = Modifier.fillMaxWidth().pointerInput(Unit) {
detectTapGestures(
onTap = {
val now = System.currentTimeMillis()
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
if (now - lastTapTime.longValue > 400) {
tapCount.intValue = 0
}
tapCount.intValue++
lastTapTime.longValue = now
if (tapCount.intValue >= 7) {
showDialog.value = true
blockTouches.value = true
}
}
)
}
Spacer(modifier = Modifier.height(4.dp))
},
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
text = "Device Info:",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Light,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 14.sp
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
textAlign = TextAlign.End,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
"MANUFACTURER=${Build.MANUFACTURER}\n" +
"MODEL=${Build.MODEL}\n" +
"BUILD_ID=${Build.ID}\n" +
"SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.hack)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.Start,
)
DeviceInfoCard()
AppInfoCard()
}
Spacer(modifier = Modifier.height(48.dp))
Text(
text = "Check the repository for more info.",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 18.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
ConfirmationDialog(
showDialog = showDialog,
title = "Confirm device check bypass?",
message = "Are you sure your device is supported with LibrePods?",
confirmText = "Yes",
dismissText = "No",
onConfirm = {
showDialog.value = false
sharedPreferences.edit {
tapCount.intValue = 0
putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
onDismiss = {
showDialog.value = false
},
hazeState = hazeState
)
return
}
@@ -314,6 +371,8 @@ fun Main() {
)
}
BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
"android.permission.BLUETOOTH_CONNECT",
@@ -361,31 +420,6 @@ fun Main() {
val navController = rememberNavController()
LaunchedEffect(Unit) {
if (BuildConfig.PLAY_BUILD) {
val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val oneDay = 24 * 60 * 60 * 1000L
if (
firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) {
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
sharedPreferences.edit {
putBoolean("review_prompted", true)
}
}
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
@@ -468,21 +502,18 @@ fun Main() {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
if (airPodsViewModel != null) UpdateHearingTestScreen()
}
composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
}
composable("hearing_protection") {
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
}
composable("equalizer_screen") {
if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel)
}
}
}
@@ -492,6 +523,10 @@ fun Main() {
navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value =
destination.route != "settings" // && destination.route != "onboarding"
Log.d(
"MainActivity",
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
)
}
}
@@ -526,12 +561,6 @@ fun Main() {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -546,7 +575,7 @@ fun Main() {
Context.BIND_AUTO_CREATE
)
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) {
if (airPodsService.value?.isConnected() == true) {
isConnected.value = true
}
} else {
@@ -557,17 +586,6 @@ fun Main() {
}
}
private fun triggerReviewFlow(activity: Activity) {
val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
val reviewInfo = task.result
manager.launchReviewFlow(activity, reviewInfo)
}
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PermissionsScreen(

View File

@@ -26,5 +26,4 @@ interface BillingProvider {
val price: StateFlow<String>
fun purchase(activity: Activity)
fun queryPurchases()
fun restorePurchases()
}

View File

@@ -31,13 +31,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlinx.coroutines.withContext
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
private val _price = MutableStateFlow("Any")
override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -57,9 +57,11 @@ class FOSSBillingProvider(context: Context): BillingProvider {
purchaseJob?.cancel()
purchaseJob = scope.launch {
delay(5_000)
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
delay(2_000)
withContext(Dispatchers.Main) {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
}
@@ -69,9 +71,4 @@ class FOSSBillingProvider(context: Context): BillingProvider {
_isPremium.value = stored
}
}
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}

View File

@@ -162,19 +162,21 @@ class PlayBillingProvider(
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
// val purchase = purchases.find {
// val navigateToPurchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (purchase != null) {
// if (navigateToPurchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken)
// .setPurchaseToken(navigateToPurchase.purchaseToken)
// .build()
// scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->}
// }
// }
_isPremium.value = hasPremium
scope.launch {
@@ -199,8 +201,4 @@ class PlayBillingProvider(
queryExistingPurchases()
}
}
override fun restorePurchases() {
queryPurchases()
}
}

View File

@@ -21,8 +21,6 @@
package me.kavishdevar.librepods.bluetooth
import android.util.Log
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.CustomEq
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -33,8 +31,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* constructing and parsing packets for communication with AirPods.
*/
class AACPManager {
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
companion object {
private const val TAG = "AACPManager"
@Suppress("unused")
object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4D
@@ -49,7 +48,7 @@ class AACPManager {
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
const val EQ_DATA: Byte = 0x53
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
const val SMART_ROUTING: Byte = 0x10
@@ -57,7 +56,6 @@ class AACPManager {
const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
const val CUSTOM_EQ: Byte = 0x63
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -111,8 +109,7 @@ class AACPManager {
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
0x37
),
PPE_CAP_LEVEL_CONFIG(0x38),
DYNAMIC_END_OF_CHARGE(0x3B);
PPE_CAP_LEVEL_CONFIG(0x38);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -202,11 +199,6 @@ class AACPManager {
var eqOnMedia: Boolean = false
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? {
return controlCommandStatusList.find { it.identifier == identifier }
}
@@ -215,7 +207,10 @@ class AACPManager {
identifier: ControlCommandIdentifiers, value: ByteArray
) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus?.value.contentEquals(value)) {
if (existingStatus == value) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus)
}
controlCommandListeners[identifier]?.forEach { listener ->
@@ -243,9 +238,7 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String)
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
fun onCustomEqReceived(customEq: CustomEq)
fun onCapabilitiesReceived(capabilities: List<Capability>)
fun onEQPacketReceived(eqData: FloatArray)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -370,13 +363,7 @@ class AACPManager {
}
val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength)
try {
keys[ProximityKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
)
}
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
Log.d(
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
@@ -421,13 +408,7 @@ class AACPManager {
}
Opcodes.CONTROL_COMMAND -> {
val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return
}
val controlCommand = ControlCommand.fromByteArray(packet)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value
@@ -558,18 +539,18 @@ class AACPManager {
}
}
Opcodes.HEADPHONE_ACCOMMODATION -> {
Opcodes.EQ_DATA -> {
if (packet.size != 140) {
Log.w(
TAG,
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
)
return
}
if (packet[6] != 0x84.toByte()) {
Log.w(
TAG,
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
)
return
}
@@ -592,7 +573,7 @@ class AACPManager {
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
)
callback?.onHeadphoneAccommodationReceived(eqData)
callback?.onEQPacketReceived(eqData)
}
Opcodes.INFORMATION -> {
@@ -601,13 +582,6 @@ class AACPManager {
callback?.onDeviceInformationReceived(information)
}
Opcodes.CUSTOM_EQ -> {
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
customEq = parseCustomEqPacket(packet)
customEqCallback?.invoke(customEq)
callback?.onCustomEqReceived(customEq)
}
else -> {
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet)
@@ -934,7 +908,7 @@ class AACPManager {
)
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
buffer.put(byteArrayOf(0x52)) // 'R'
buffer.put("HostStreamingState".toByteArray())
@@ -1098,25 +1072,25 @@ class AACPManager {
companion object {
fun fromByteArray(data: ByteArray): ControlCommand {
var offset = 0
while (data.size - offset >= 4 &&
data[offset] == 0x04.toByte() &&
data[offset + 1] == 0x00.toByte() &&
data[offset + 2] == 0x04.toByte() &&
data[offset + 3] == 0x00.toByte()
) {
offset += 4
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse ControlCommand")
}
if (data.size - offset < 7) {
throw IllegalArgumentException("Too short for ControlCommand")
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
val newData = ByteArray(data.size - 4)
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
}
if (data[offset] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Invalid opcode")
if (data[0] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
}
val identifier = data[offset + 2]
val value = data.copyOfRange(offset + 3, offset + 7)
val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
val identifier = data[2]
val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 4)
val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
return ControlCommand(identifier, finalValue)
}
}
}
@@ -1142,13 +1116,7 @@ class AACPManager {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Invalid control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return false
}
val controlCommand = ControlCommand.fromByteArray(packet)
Log.d(
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) }
@@ -1159,7 +1127,7 @@ class AACPManager {
)
}
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
if (socket.isConnected) {
socket.outputStream?.write(packet)
@@ -1313,38 +1281,4 @@ class AACPManager {
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
)
}
}

View File

@@ -16,196 +16,229 @@
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
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 java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
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.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A)
HEARING_AID(0x2A),
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
}
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object {
private const val TAG = "ATTManager"
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
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")
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
}
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: Job? = null
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
@SuppressLint("MissingPermission")
fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
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
socket = createBluetoothSocket(adapter, device, uuid)
try {
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
socket!!.connect()
} catch (e: Exception) {
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
}
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
Log.w(TAG, "ATT socket failed to connect")
return
}
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
val input = socket.inputStream
val buffer = ByteArray(512)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
if (data.isEmpty()) continue
val opcode = data[0]
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 {
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
notificationJob = CoroutineScope(Dispatchers.IO).launch {
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) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
}
}
} else {
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
// 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() {
try {
notificationJob?.cancel()
socket?.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
}
}
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
}
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
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) {
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 len = inp.read(buffer)
if (len == -1) {
disconnect()
throw IllegalStateException("End of stream reached")
}
val data = buffer.copyOfRange(0, len)
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
// wait for a response PDU produced by the background reader
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
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, 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) {
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
readerRunning.set(false)
}
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
}
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("ATTManager", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
}

View File

@@ -18,22 +18,23 @@
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private var aacpSocket: BluetoothSocket? = null
private var attSocket: BluetoothSocket? = null
private const val TAG = "BluetoothConnectionManager"
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
BluetoothConnectionManager.aacpSocket = aacpSocket
BluetoothConnectionManager.attSocket = attSocket
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
}
fun getAACPSocket(): BluetoothSocket? {
return aacpSocket
}
fun getATTSocket(): BluetoothSocket? {
return attSocket
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
}
}

View File

@@ -1,27 +0,0 @@
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" }
}
}

View File

@@ -26,6 +26,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -137,15 +138,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
}
fun sendHearingAidSettings(
currentData: ByteArray,
attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
debounceJob: MutableState<Job?>
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
@@ -183,7 +184,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
sender(ATTHandles.HEARING_AID, currentData)
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -22,10 +22,8 @@ import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
// TODO: Remove everything but Battery-related stuff
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(byteArrayOf(0x0d)),
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
@@ -74,7 +72,6 @@ enum class NoiseControlMode {
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
@@ -83,12 +80,12 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
}
class EarDetection {
private val notificationBit = 6.toByte()
private val notificationBit = Capabilities.EAR_DETECTION
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
@@ -245,6 +242,13 @@ class AirPodsNotifications {
}
}
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val EAR_DETECTION = byteArrayOf(0x06)
}
}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false

View File

@@ -83,8 +83,7 @@ data class TransparencySettings(
}
}
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float

View File

@@ -1,193 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@Composable
fun AppInfoCard() {
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
Column {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, bottom = 8.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.about), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_NAME, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version_code), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.flavor), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.FLAVOR, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_type), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.BUILD_TYPE,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -53,7 +53,6 @@ fun AudioSettings(
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
customEqCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
@@ -158,20 +157,6 @@ fun AudioSettings(
navController = navController,
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
)
}
}
}
@@ -185,7 +170,6 @@ fun AudioSettingsPreview() {
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
customEqCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,

View File

@@ -18,32 +18,37 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -51,13 +56,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@@ -70,107 +75,162 @@ fun ConfirmationDialog(
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
backdrop: LayerBackdrop,
hazeState: HazeState,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
AnimatedVisibility(
visible = showDialog.value,
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut()
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
if (showDialog.value) {
Dialog(
onDismissRequest = { showDialog.value = false },
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
// .fillMaxWidth(0.75f)
.requiredWidthIn(min = 200.dp, max = 360.dp)
.background(Color.Transparent, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.hazeEffect(
hazeState,
style = CupertinoMaterials.regular(
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
)
)
) {
Box(
modifier = Modifier
.requiredWidthIn(min = 200.dp, max = 360.dp)
.clip(RoundedCornerShape(48.dp))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(48.dp) },
effects = {
vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.fillMaxWidth()
)
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val position = event.changes.first().position
val width = size.width.toFloat()
val height = size.height.toFloat()
val isWithinBounds = position.y >= 0 && position.y <= height
val isLeft = position.x < width / 2
event.changes.first().consume()
when (event.type) {
PointerEventType.Press -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Move -> {
if (isWithinBounds) {
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Reject) }
onDismiss()
} else if (rightPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Confirm) }
onConfirm()
}
}
leftPressed = false
rightPressed = false
}
}
}
}
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
)
})) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(24.dp)
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = dismissText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = textColor
)
Text(
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
surfaceColor = accentColor
) {
Text(
text = confirmText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = Color.White
)
)
}
)
}
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(Color(0x40888888))
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = confirmText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View File

@@ -1,235 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, top = 24.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.device_info), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.manufacturer), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MANUFACTURER, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_number), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MODEL, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_id), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.DISPLAY, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.xposed_available), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.app_enabled_in_xposed), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -1,87 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledBottomSheet(
visible: Boolean,
onDismiss: () -> Unit,
backdrop: LayerBackdrop,
content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit
) {
if (!visible) return
val isDarkTheme = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
val isExpanded = sheetState.targetValue == SheetValue.Expanded
val progress by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
label = "sheetProgress"
)
val animatedCorner = lerp(48.dp, 42.dp, progress)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(animatedCorner),
scrimColor = Color.Transparent,
modifier = Modifier.padding(4.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(animatedCorner))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(animatedCorner) },
effects = {
vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
0xFFE0E0E0
).copy(alpha = 0.45f)
)
}
)
.padding(top = 24.dp)
.padding(horizontal = 16.dp)
) {
content(innerBackdrop, progress)
}
}
}

View File

@@ -77,10 +77,8 @@ fun StyledButton(
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) }
@@ -127,8 +125,8 @@ half4 main(float2 coord) {
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified && enabled) {
val color = if (isPressed) {
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
@@ -139,11 +137,6 @@ half4 main(float2 coord) {
surfaceColor
}
drawRect(color)
} else {
if (isPressed && enabled) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
}
},
onDrawFront = null,
@@ -252,10 +245,8 @@ half4 main(float2 coord) {
indication = null,
role = Role.Button,
onClick = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
)
.then(
@@ -264,38 +255,29 @@ half4 main(float2 coord) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch {
progressAnimation.animateTo(
0f,
progressAnimationSpec
)
}
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) }
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = {
@@ -303,13 +285,11 @@ half4 main(float2 coord) {
},
onDragCancel = onDragStop
) { _, dragAmount ->
if (enabled) {
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
@@ -322,10 +302,8 @@ half4 main(float2 coord) {
isPressed = false
},
onTap = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
)
}

View File

@@ -52,7 +52,6 @@ import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -82,11 +81,9 @@ import kotlin.math.tanh
fun StyledIconButton(
modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true
onClick: () -> Unit
) {
val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
@@ -99,7 +96,6 @@ fun StyledIconButton(
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val density = LocalDensity.current
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -124,10 +120,8 @@ half4 main(float2 coord) {
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
}
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
},
shape = RoundedCornerShape(56.dp),
modifier = modifier
@@ -143,7 +137,6 @@ half4 main(float2 coord) {
)
},
layerBlock = {
if (!enabled) return@drawBackdrop
val width = size.width
val height = size.height
@@ -168,12 +161,6 @@ half4 main(float2 coord) {
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
if (!enabled) {
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
)
return@drawBackdrop
}
val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp)
@@ -200,10 +187,6 @@ half4 main(float2 coord) {
}
drawLayer(innerShadowLayer)
if (surfaceColor.isSpecified) {
drawRect(surfaceColor)
}
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn(
@@ -214,7 +197,6 @@ half4 main(float2 coord) {
)
},
onDrawFront = {
if (!enabled) return@drawBackdrop
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
@@ -259,46 +241,40 @@ half4 main(float2 coord) {
)
.pointerInput(scope) {
val onDragStop: () -> Unit = {
if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
if (enabled) {
pressStartPosition = down.position
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
pressStartPosition = down.position
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
scope.launch {
if (enabled) {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
.size(with(density) { 48.sp.toDp() }),
.size(48.dp),
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)

View File

@@ -1,154 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import android.R.attr.singleLine
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun StyledInputField(
inputState: TextFieldState,
focusRequester: FocusRequester,
placeholder: String = "",
singleLine: Boolean = true
){
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val minHeight = if (singleLine) 58.dp else 120.dp
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
val hasText = inputState.text.isNotEmpty()
val density = LocalDensity.current
val spacerHeight by animateDpAsState(
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
label = "labelSpacer"
)
val transition = updateTransition(hasText, label = "floating")
val yOffset by transition.animateDp(label = "y") {
if (it) with (density) { (-48).sp.toDp() } else 0.dp
}
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = verticalAlignment,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures {
focusRequester.requestFocus()
}
}
) {
BasicTextField(
state = inputState,
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
verticalAlignment = verticalAlignment,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box(
modifier = Modifier
.weight(1f),
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
) {
Text(
text = placeholder,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.8f)
),
modifier = Modifier
.offset(y = yOffset)
)
innerTextField()
}
}
if (singleLine && !inputState.text.isEmpty()) {
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.6f
)
),
)
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
}
}

View File

@@ -77,7 +77,7 @@ fun StyledScaffold(
.clip(RoundedCornerShape(52.dp))
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding() + 16.dp
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)

View File

@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -40,6 +39,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -74,6 +74,7 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
Column(
@@ -99,7 +100,7 @@ fun StyledSelectList(
Row(
modifier = Modifier
.heightIn(min = if (hasIcon) 72.dp else 55.dp)
.height(if (hasIcon) 72.dp else 55.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(

View File

@@ -33,7 +33,6 @@ import android.content.IntentFilter
import android.content.res.Resources
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -375,7 +374,6 @@ class IslandWindow(private val context: Context) {
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
@@ -714,16 +712,8 @@ class IslandWindow(private val context: Context) {
}
isClosing = false
// Make sure all animations are canceled
try {
springAnimation.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling spring animation $e")
}
try {
flingAnimator.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling fling animation $e")
}
springAnimation.cancel()
flingAnimator.cancel()
}
fun forceClose() {

View File

@@ -29,7 +29,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -65,16 +64,10 @@ class PopupWindow(
@Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
height = WindowManager.LayoutParams.WRAP_CONTENT
val displayMetrics = context.resources.displayMetrics
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
width = if (screenWidthDp >= 600) {
(400 * displayMetrics.density).toInt()
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
width = WindowManager.LayoutParams.MATCH_PARENT
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
gravity = Gravity.BOTTOM
dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or
@@ -91,6 +84,7 @@ class PopupWindow(
mParams.x = 0
mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener {
close()
}
@@ -138,7 +132,6 @@ class PopupWindow(
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
vid.start()

View File

@@ -20,7 +20,6 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
@@ -40,7 +39,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -73,10 +71,6 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
@@ -91,8 +85,8 @@ import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null
//private var phoneMediaDebounceJob: Job? = null
//private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@@ -105,13 +99,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
)
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
)?.toInt() == 1
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1
val backdrop = rememberLayerBackdrop()
@@ -137,7 +125,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
@@ -161,10 +149,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
@@ -177,10 +162,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0)
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
@@ -193,10 +175,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
)
val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
@@ -204,42 +183,43 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
)
}
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(
"AccessibilitySettingsScreen",
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
)
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(
"AccessibilitySettingsScreen",
"Error sending phone/media EQ: ${e.message}"
)
}
}
}
Box(
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
// phoneMediaDebounceJob?.cancel()
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
// delay(150)
// val manager = ServiceManager.getService()?.aacpManager
// if (manager == null) {
// Log.w(TAG, "Cannot write EQ: AACPManager not available")
// return@launch
// }
// try {
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// Log.d(
// TAG,
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
// )
// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
// } catch (e: Exception) {
// Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
// }
// }
// }
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
@@ -259,18 +239,21 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
)
}
Box(
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
if (!state.isPremium) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
@@ -295,14 +278,8 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) },
enabled = state.isPremium
)
@@ -311,12 +288,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) },
enabled = state.isPremium
)
}
@@ -330,19 +302,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
)
}
val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue,
onValueChange = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50))
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
@@ -353,34 +319,30 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
)
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) },
enabled = state.isPremium
)
Box(
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
@@ -402,22 +364,21 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
}
}
// if (!hearingAidEnabled && XposedState.isAvailable) {
// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") {
// Text(
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
// text = stringResource(R.string.apply_eq_to),
// style = TextStyle(
// fontSize = 14.sp,
// fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro))
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// ),
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
// )
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .background(backgroundColor, RoundedCornerShape(28.dp))
// .padding(vertical = 0.dp)
// ) {
// val darkModeLocal = isSystemInDarkTheme()
@@ -444,19 +405,17 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures(
// onPress = {
// phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// phoneEQEnabled.value = !phoneEQEnabled.value
// })
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically) {
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.phone),
// fontSize = 16.sp,
@@ -482,7 +441,8 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// }
//
// HorizontalDivider(
// thickness = 1.dp, color = Color(0x40888888)
// thickness = 1.dp,
// color = Color(0x40888888)
// )
//
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
@@ -507,19 +467,17 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures(
// onPress = {
// mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// mediaEQEnabled.value = !mediaEQEnabled.value
// })
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically) {
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.media),
// fontSize = 16.sp,
@@ -544,97 +502,90 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// )
// }
// }
//
//// EQ Settings. Don't seem to have an effect?
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
// ) {
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
//
// for (i in 0 until 8) {
// val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
// Row(
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
//
// Slider(
// value = eqPhoneValue.floatValue,
// onValueChange = { newVal ->
// eqPhoneValue.floatValue = newVal
// val newEQ = phoneMediaEQ.value.copyOf()
// newEQ[i] = eqPhoneValue.floatValue
// phoneMediaEQ.value = newEQ
// },
// valueRange = 0f..100f,
// modifier = Modifier
// .fillMaxWidth(0.9f)
// .height(36.dp),
// colors = SliderDefaults.colors(
// thumbColor = thumbColor,
// activeTrackColor = activeTrackColor,
// inactiveTrackColor = trackColor
// ),
// thumb = {
// Box(
// modifier = Modifier
// .size(24.dp)
// .shadow(4.dp, CircleShape)
// .background(thumbColor, CircleShape)
// )
// },
// track = {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(12.dp),
// contentAlignment = Alignment.CenterStart
// ) {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(4.dp)
// .background(trackColor, RoundedCornerShape(4.dp))
// )
// Box(
// modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp)
// .background(
// activeTrackColor, RoundedCornerShape(4.dp)
// )
// )
// }
// })
//
// Text(
// text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(top = 4.dp)
// )
// }
// }
// }
// }
// EQ Settings. Don't seem to have an effect?
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// .padding(12.dp),
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// for (i in 0 until 8) {
// val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
// Row(
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
// Slider(
// value = eqPhoneValue.floatValue,
// onValueChange = { newVal ->
// eqPhoneValue.floatValue = newVal
// val newEQ = phoneMediaEQ.value.copyOf()
// newEQ[i] = eqPhoneValue.floatValue
// phoneMediaEQ.value = newEQ
// },
// valueRange = 0f..100f,
// modifier = Modifier
// .fillMaxWidth(0.9f)
// .height(36.dp),
// colors = SliderDefaults.colors(
// thumbColor = thumbColor,
// activeTrackColor = activeTrackColor,
// inactiveTrackColor = trackColor
// ),
// thumb = {
// Box(
// modifier = Modifier
// .size(24.dp)
// .shadow(4.dp, CircleShape)
// .background(thumbColor, CircleShape)
// )
// },
// track = {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(12.dp),
// contentAlignment = Alignment.CenterStart
// )
// {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(4.dp)
// .background(trackColor, RoundedCornerShape(4.dp))
// )
// Box(
// modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp)
// .background(activeTrackColor, RoundedCornerShape(4.dp))
// )
// }
// }
// )
// Text(
// text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(top = 4.dp)
// )
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -665,7 +616,7 @@ private fun DropdownMenuComponent(
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()){
Column(
modifier = Modifier
.fillMaxWidth()
@@ -679,14 +630,14 @@ private fun DropdownMenuComponent(
} else Modifier
)
.background(
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
0xFFFFFFFF
)) else Color.Transparent,
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent,
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
)
) {
)
then(
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
)
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
){
Row(
modifier = Modifier
.fillMaxWidth()
@@ -707,94 +658,98 @@ private fun DropdownMenuComponent(
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffset = offset
if (!expanded && now - lastDismissTime > 250L) {
expanded = true
}
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
}, onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffset = offset
if (!expanded && now - lastDismissTime > 250L) {
expanded = true
}
}
parentHoveredIndex = idx
previousIdx = idx
}, onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
parentHoveredIndex = idx
previousIdx = idx
},
onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null
},
onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
}
parentHoveredIndex = null
}, onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
})
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
){
Text(
text = label,
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
if (!independent && description != null) {
if (!independent && description != null){
Text(
text = description, style = TextStyle(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
),
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
)
}
}
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent()
}) {
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedOption, style = TextStyle(
text = selectedOption,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "􀆏", style = TextStyle(
text = "􀆏",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 6.dp)
),
modifier = Modifier
.padding(start = 6.dp)
)
}
@@ -819,22 +774,19 @@ private fun DropdownMenuComponent(
}
}
}
if (independent && description != null) {
if (independent && description != null){
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
)
) {
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
){
Text(
text = description, style = TextStyle(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)

View File

@@ -51,10 +51,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
@@ -81,7 +81,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
@@ -95,7 +95,11 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
}
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
mutableFloatStateOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
0
)?.toFloat() ?: 50f
)
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()

View File

@@ -23,20 +23,15 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -55,7 +50,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
@@ -69,7 +63,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
@@ -99,7 +92,6 @@ import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -138,6 +130,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
viewModel.refreshInitialData()
}
isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
StyledScaffold(
@@ -177,44 +170,6 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
} else Modifier)) {
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") {
BatteryView(
batteryList = state.battery,
@@ -284,11 +239,13 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") {
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try { bytes[1] == 0x02.toByte() } catch (e: Exception) { false }
val flipped =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
CallControlSettings(
hazeState = hazeState,
flipped = flipped,
flipped = flipped == true,
onCallControlValueChanged = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
@@ -320,7 +277,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900
)
) {
@@ -365,7 +322,6 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
conversationalAwarenessCapability = conversationalAwarenessCapability,
loudSoundReductionCapability = loudSoundReductionCapability,
adaptiveAudioCapability = adaptiveAudioCapability,
customEqCapability = true,
adaptiveVolumeChecked = adaptiveVolumeChecked,
onAdaptiveVolumeCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
@@ -443,16 +399,6 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
}
item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "dynamic_end_of_charge") {
StyledToggle(
label = stringResource(R.string.optimized_charging),
description = stringResource(R.string.optimized_charging_description),
checked = state.dynamicEndOfCharge,
onCheckedChange = viewModel::setDynamicEndOfCharge
)
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") {
NavigationButton(
@@ -486,30 +432,6 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
)
}
item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
item(key = "disconnect_button") {
StyledButton(
onClick = viewModel::disconnect,
backdrop = rememberLayerBackdrop(),
isInteractive = false,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
) {
Text(
text = stringResource(R.string.disconnect),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
@@ -597,22 +519,19 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
Spacer(Modifier.height(16.dp))
}
if (state.connectionSuccessful) {
StyledButton(
onClick = {
viewModel.reconnectFromSavedMac()
}, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
StyledButton(
onClick = {
viewModel.reconnectFromSavedMac()
}, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device), style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
}
)
}
}
}

View File

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

View File

@@ -99,9 +99,9 @@ import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
@@ -151,13 +151,9 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column (
@@ -167,6 +163,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
.layerBackdrop(backdrop)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(topPadding))
@@ -178,7 +175,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
@@ -197,7 +194,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
label = "Head Gestures",
checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium || state.headGesturesEnabled,
enabled = state.isPremium,
description = stringResource(R.string.head_gestures_details)
)

View File

@@ -32,12 +32,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -47,17 +48,22 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
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.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@@ -68,83 +74,14 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val debounceJob = remember { mutableStateOf<Job?>(null) }
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 initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember { mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = 0f,
rightAmplification = 0f,
leftTone = 0f,
rightTone = 0f,
leftConversationBoost = false,
rightConversationBoost = false,
leftAmbientNoiseReduction = 0f,
rightAmbientNoiseReduction = 0f,
netAmplification = 0f,
balance = 0f,
ownVoiceAmplification = 0f
)
) }
LaunchedEffect(state.hearingAidData) {
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
initialized.value = true
}
}
LaunchedEffect(
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
StyledScaffold(
title = stringResource(R.string.adjustments)
) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
@@ -156,6 +93,136 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
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 initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} 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) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
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(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
@@ -168,6 +235,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),

View File

@@ -259,10 +259,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {
@@ -274,7 +270,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
hearingAidEnabled.value = false
showDialog.value = false
},
// hazeState = hazeStateS.value,
backdrop = backdrop
hazeState = hazeStateS.value,
// backdrop = backdrop
)
}

View File

@@ -18,39 +18,29 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold(
@@ -63,27 +53,7 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavContr
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -33,8 +34,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -42,17 +48,19 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
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.data.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -74,7 +82,12 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = name
@@ -92,14 +105,16 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES)
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
}
),
SelectItem(
name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT)
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
},
enabled = state.isPremium
)
@@ -115,7 +130,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
@@ -147,10 +162,21 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
Spacer(modifier = Modifier.height(8.dp))
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
val allowOff = offListeningModeValue == 1.toByte()
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
?.get(0)?.toInt()
?: sharedPreferences.getInt("long_press_byte", 0b0101)
var currentByte by remember { mutableIntStateOf(initialByte) }
val listeningModeItems = mutableListOf<SelectItem>()
if (state.offListeningMode) {
if (allowOff) {
listeningModeItems.add(
SelectItem(
name = stringResource(R.string.off),
@@ -158,7 +184,21 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0,
onClick = {
viewModel.toggleListeningMode(0x01)
val bit = 0x01
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
)
)
@@ -170,7 +210,21 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0,
onClick = {
viewModel.toggleListeningMode(0x04)
val bit = 0x04
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
),
SelectItem(
@@ -179,7 +233,21 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0,
onClick = {
viewModel.toggleListeningMode(0x08)
val bit = 0x08
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
),
SelectItem(
@@ -188,7 +256,21 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0,
onClick = {
viewModel.toggleListeningMode(0x02)
val bit = 0x02
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
)
))
@@ -208,4 +290,14 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
}
}
}
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
}
fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
return count
}

View File

@@ -53,11 +53,11 @@ import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
@@ -99,7 +99,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.free_features),
text = "Free features",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -199,7 +199,7 @@ fun PurchaseScreen(
)
)
}
if (XposedState.isAvailable) {
if (BuildConfig.FLAVOR == "xposed") {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
@@ -242,7 +242,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.advanced_features),
text = "Advanced features",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
@@ -288,36 +288,6 @@ fun PurchaseScreen(
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
@@ -486,11 +456,10 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.buy_price, state.price),
stringResource(R.string.buy),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
@@ -509,7 +478,6 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
isInteractive = false
) {
Text(
stringResource(R.string.restore_purchases),

View File

@@ -21,26 +21,45 @@
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -50,36 +69,89 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
name.value = name.value.copy(selection = TextRange(name.value.text.length))
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.name),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val name = sharedPreferences.getString("name", "")?: ""
val textFieldState = rememberTextFieldState(initialText = name)
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isDarkTheme) Color.White else Color.Black
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(58.dp)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BasicTextField(
value = name.value,
onValueChange = {
name.value = it
sharedPreferences.edit {putString("name", it.text)}
viewModel.setName(it.text)
},
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
singleLine = true,
cursorBrush = SolidColor(cursorColor),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
innerTextField()
}
IconButton(
onClick = {
name.value = TextFieldValue("")
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
),
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
StyledInputField(
textFieldState,
focusRequester
)
}
}
}

View File

@@ -46,10 +46,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -65,14 +64,17 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
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.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@@ -110,26 +112,19 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = rememberSaveable { 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 eq = rememberSaveable(
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 enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialized = rememberSaveable { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val transparencySettings = remember {
mutableStateOf(
@@ -158,9 +153,23 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value
eq.value,
initialLoadComplete.value,
initialReadSucceeded.value
) {
if (!initialized.value) return@LaunchedEffect
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
@@ -180,20 +189,59 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(state.transparencyData) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
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")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
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) {

View File

@@ -35,14 +35,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -58,19 +57,34 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
fun UpdateHearingTestScreen() {
val verticalScrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
Text(
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textAlign = TextAlign.Center
)
return
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
@@ -98,31 +112,18 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
),
textAlign = TextAlign.Center,
)
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
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 tone = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
@@ -144,21 +145,31 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
)
}
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
@@ -166,13 +177,27 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
@@ -189,7 +214,55 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.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 =

View File

@@ -23,13 +23,12 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,9 +38,7 @@ import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -50,7 +47,6 @@ import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
@@ -93,14 +89,7 @@ data class AirPodsUiState(
val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false,
val vendorIdHook: Boolean = false,
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
val vendorIdHook: Boolean = false
)
class AirPodsViewModel(
@@ -122,6 +111,8 @@ class AirPodsViewModel(
private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>()
private var billingFirstCollectDone = false
private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
@@ -143,40 +134,15 @@ class AirPodsViewModel(
_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 {
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
observeAACP()
loadControlList()
loadEq()
loadATT()
observeATT()
observeSharedPreferences()
setupControlObservers()
observeBilling()
loadControlList()
observeATT()
if (isDemoMode) activateDemoMode()
}
@@ -184,7 +150,7 @@ class AirPodsViewModel(
listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener)
}
service.aacpManager.customEqCallback = null
appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
@@ -198,50 +164,32 @@ class AirPodsViewModel(
private fun observeBilling() {
if (isDemoMode) return
viewModelScope.launch {
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium ->
// if (!billingFirstCollectDone) {
// billingFirstCollectDone = true
// return@collect
// }
if (premium) {
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(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
_uiState.update { it.copy(isPremium = false) }
}
if (!billingFirstCollectDone) {
billingFirstCollectDone = true
return@collect
}
if (!premium) {
Log.d("AirPodsViewModel", "we are not premium")
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
} else {
Log.d("AirPodsViewModel", "we are premium")
}
_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() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
if (!isDemoMode) when (action) {
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
if (!isDemoMode) when (intent?.action) {
AirPodsNotifications.AIRPODS_CONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = true)
}
@@ -254,8 +202,10 @@ class AirPodsViewModel(
}
AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
?.toList() ?: emptyList()
_uiState.update {
it.copy(battery = service.getBattery())
it.copy(battery = data)
}
}
@@ -324,24 +274,17 @@ class AirPodsViewModel(
val current = state.controlStates[identifier]
if (current?.contentEquals(value) == true) return@update state
if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) {
state.copy(
dynamicEndOfCharge = value[0] == 0x01.toByte(),
controlStates = state.controlStates + (identifier to value)
)
} else {
state.copy(
controlStates = state.controlStates + (identifier to value)
)
}
state.copy(
controlStates = state.controlStates + (identifier to value)
)
}
}
listeners[identifier] = listener
listeners[identifier] = listener as AACPManager.ControlCommandListener
}
// I'm lazy, sorry.
fun observeAACP() {
fun setupControlObservers() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
@@ -368,14 +311,10 @@ class AirPodsViewModel(
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
ControlCommandIdentifiers.OWNS_CONNECTION,
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE
)
for (identifier in identifiersList) {
observeControl(identifier)
}
service.aacpManager.customEqCallback = { customEq ->
_uiState.update { it.copy(customEq = customEq) }
}
}
fun refreshInitialData() {
@@ -383,7 +322,7 @@ class AirPodsViewModel(
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery()
isLocallyConnected = service.isConnected(), battery = service.getBattery()
)
}
}
@@ -409,9 +348,6 @@ class AirPodsViewModel(
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false)
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
_uiState.update {
it.copy(
@@ -421,65 +357,15 @@ class AirPodsViewModel(
headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction,
rightAction = rightAction,
vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
connectionSuccessful = connectionSuccessful,
vendorIdHook = vendorIdHook
)
}
// 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) {
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
Log.d("AirPodsViewModel", "Hello???? $enabled")
_uiState.update {
it.copy(offListeningMode = enabled)
}
@@ -492,14 +378,6 @@ class AirPodsViewModel(
}
}
fun setDynamicEndOfCharge(enabled: Boolean) {
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
_uiState.update {
it.copy(dynamicEndOfCharge = enabled)
}
}
private fun loadControlList() {
_uiState.update {
it.copy(
@@ -508,14 +386,6 @@ class AirPodsViewModel(
}
}
private fun loadEq() {
_uiState.update {
it.copy(
customEq = service.aacpManager.customEq
)
}
}
private fun loadInstance() {
val instance = service.airpodsInstance ?: AirPodsInstance(
name = "AirPods",
@@ -566,69 +436,40 @@ class AirPodsViewModel(
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
when (handle) {
// 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)
}
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
}
viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
service.attManager?.write(handle, value)
}
}
fun loadATT() {
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
_uiState.update {
it.copy(
loudSoundReductionEnabled = loudSoundReductionEnabled,
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAid =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
transparencyData = transparencyData,
hearingAidData = hearingAidData
hearingAidData = hearingAid
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
// 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?.connect()
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(10000)
}
}
}
@@ -653,6 +494,10 @@ class AirPodsViewModel(
}
}
// fun purchase(context: Context) {
// BillingManager.provider.purchase(context as Activity)
// }
fun activateDemoMode() {
isDemoMode = true
viewModelScope.launch {
@@ -685,50 +530,8 @@ class AirPodsViewModel(
modelName = fakeInstance.model.displayName,
actualModel = fakeInstance.actualModelNumber,
serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
version3 = "Demo Firmware",
isPremium = true
version3 = "Demo Firmware"
)
}
}
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
}
fun setLongPressAction(side: String, action: StemAction) {
val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
sharedPreferences.edit { putString(prefKey, action.name) }
_uiState.update {
if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action)
}
}
private fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
return count
}
fun toggleListeningMode(modeBit: Int) {
val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val newValue = if ((currentByte and modeBit) != 0) {
val temp = currentByte and modeBit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or modeBit
}
setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte())
sharedPreferences.edit { putInt("long_press_byte", newValue) }
}
fun disconnect() {
service.disconnectAirPods()
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(appContext, "App has disconnected, disconnect from Android Settings.",
Toast.LENGTH_LONG).show()
}
}
}

View File

@@ -2,7 +2,6 @@ package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -13,6 +12,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
import kotlin.math.roundToInt
data class AppSettingsUiState(
@@ -32,11 +32,7 @@ data class AppSettingsUiState(
val cameraPackageValue: String = "",
val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false,
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L
val isPremium: Boolean = false
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -47,92 +43,20 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private val xposedRemotePref = XposedRemotePrefProvider.create()
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
if (key == "connection_successful") {
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
}
}
init {
loadSettings()
observeBilling()
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
}
override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
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) }
}
}
_uiState.update { it.copy(isPremium = premium) }
}
}
}
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 ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
@@ -148,12 +72,12 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true)
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
)
}
if (BuildConfig.FLAVOR == "xposed") {
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
}
}
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
@@ -238,17 +162,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
}
fun setVendorIdHook(enabled: Boolean) {
NativeBridge.setSdpHook(enabled)
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) }
}
fun setShowBottomSheetPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
_uiState.update { it.copy(showBottomSheetPopup = enabled) }
}
fun setShowIslandPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
_uiState.update { it.copy(showIslandPopup = enabled) }
}
}

View File

@@ -42,6 +42,6 @@ class PurchaseViewModel(application: Application) : AndroidViewModel(application
}
fun restorePurchases() {
BillingManager.provider.restorePurchases()
BillingManager.provider.queryPurchases()
}
}

View File

@@ -28,8 +28,8 @@ import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() {
@@ -82,14 +82,8 @@ class NoiseControlWidget : AppWidgetProvider() {
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
val service = ServiceManager.getService()
if (service == null) {
Log.w("NoiseControlWidget", "Service unavailable")
return
}
service.aacpManager
ServiceManager.getService()!!
.aacpManager
.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
mode.toByte()

View File

@@ -35,10 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
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.NoiseControlMode
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)
@@ -99,7 +98,7 @@ class AirPodsQSService : TileService() {
Log.d("AirPodsQSService", "onStartListening")
val service = ServiceManager.getService()
isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true
isAirPodsConnected = service?.isConnected() == true
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {

View File

@@ -171,10 +171,8 @@ object MediaController {
}
if (configs != null && !iPausedTheMedia) {
val localMac = ServiceManager.getService()?.localMac ?: return
if (localMac == "") return
ServiceManager.getService()?.aacpManager?.sendMediaInformataion(
localMac,
ServiceManager.getService()?.localMac ?: return,
isActive
)
Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play")

View File

@@ -20,17 +20,25 @@ package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
import android.os.Build
import me.kavishdevar.librepods.BuildConfig
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 isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
if (isPixel && Build.VERSION.SDK_INT == 36) {
return Build.ID.startsWith("CP1A")
} else if (isOppoFamily) {
return Build.VERSION.SDK_INT >= 36
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
}
37 -> {
return true
}
}
} else if (isOppoOrOnePlus) {
return true
}
return false
return if (BuildConfig.FLAVOR == "xposed") true
else sharedPreferences.getBoolean("bypass_device_check", false)
}

View File

@@ -1,6 +0,0 @@
package me.kavishdevar.librepods.utils
object XposedState {
var isAvailable: Boolean = false
var bluetoothScopeEnabled: Boolean = false
}

View File

@@ -1,7 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="popup_animations">Popup-Animationen</string>
<string name="show_bottom_sheet_popup">Popup unten</string>
<string name="show_bottom_sheet_popup_description">Zeigt das Popup im iOS-Stil unten an, wenn AirPods sich verbinden.</string>
<string name="show_island_popup">Dynamic Island Popup</string>
<string name="show_island_popup_description">Zeigt das Popup im Dynamic-Island-Stil oben für Verbindungs- und Übergabe-Ereignisse.</string>
</resources>

View File

@@ -210,9 +210,4 @@
<string name="listening_mode_transparency_description">Deja entrar los sonidos externos</string>
<string name="listening_mode_adaptive_description">Ajuste dinámico del ruido externo</string>
<string name="listening_mode_noise_cancellation_description">Bloquea los sonidos externos</string>
<string name="popup_animations">Animaciones emergentes</string>
<string name="show_bottom_sheet_popup">Ventana emergente inferior</string>
<string name="show_bottom_sheet_popup_description">Muestra la ventana emergente estilo iOS en la parte inferior cuando los AirPods se conectan.</string>
<string name="show_island_popup">Ventana emergente Dynamic Island</string>
<string name="show_island_popup_description">Muestra la ventana emergente estilo Dynamic Island en la parte superior para eventos de conexión y traspaso.</string>
</resources>

View File

@@ -210,9 +210,4 @@
<string name="listening_mode_transparency_description">Laisser entrer les sons extérieurs</string>
<string name="listening_mode_adaptive_description">Ajuster dynamiquement les sons extérieurs</string>
<string name="listening_mode_noise_cancellation_description">Bloquer les sons extérieurs</string>
<string name="popup_animations">Animations contextuelles</string>
<string name="show_bottom_sheet_popup">Fenêtre contextuelle en bas</string>
<string name="show_bottom_sheet_popup_description">Afficher la fenêtre contextuelle de style iOS en bas de l\'écran lors de la connexion des AirPods.</string>
<string name="show_island_popup">Fenêtre Dynamic Island</string>
<string name="show_island_popup_description">Afficher la fenêtre de style Dynamic Island en haut de l\'écran pour les événements de connexion et de transfert.</string>
</resources>

View File

@@ -210,9 +210,4 @@
<string name="listening_mode_transparency_description">Permite sons externos</string>
<string name="listening_mode_adaptive_description">Ajusta dinamicamente o ruído externo</string>
<string name="listening_mode_noise_cancellation_description">Bloqueia sons externos</string>
<string name="popup_animations">Animações de pop-up</string>
<string name="show_bottom_sheet_popup">Pop-up inferior</string>
<string name="show_bottom_sheet_popup_description">Exibe o pop-up estilo iOS na parte inferior quando os AirPods se conectam.</string>
<string name="show_island_popup">Pop-up Dynamic Island</string>
<string name="show_island_popup_description">Exibe o pop-up estilo Dynamic Island no topo da tela em eventos de conexão e transferência.</string>
</resources>

View File

@@ -152,7 +152,7 @@
<string name="set_identity_resolving_key">設定身分解析金鑰 (IRK)</string>
<string name="set_identity_resolving_key_description">手動設定用於解析 BLE 隨機位址的 IRK 值</string>
<string name="set_encryption_key">設定加密金鑰</string>
<string name="set_encryption_key_description">手動設定用於解密 BLE 廣播的 ENC_KEY </string>
<string name="set_encryption_key_description">手動設定用於解密 BLE 廣播的 ENC_KEY值</string>
<string name="use_alternate_head_tracking_packets">使用替代頭部追蹤封包</string>
<string name="use_alternate_head_tracking_packets_description">如果頭部追蹤對你無效,請啟用此選項。這會傳送不同的資料給 AirPods 以請求/停止頭部追蹤資料。</string>
<string name="act_as_an_apple_device">作為 Apple 裝置</string>
@@ -212,33 +212,4 @@
<string name="listening_mode_transparency_description">允許外部聲音</string>
<string name="listening_mode_adaptive_description">動態調整外部噪音</string>
<string name="listening_mode_noise_cancellation_description">阻隔外部聲音</string>
<string name="unlock_advanced_features">解鎖進階功能</string>
<string name="buy_price">購買 %s</string>
<string name="restore_purchases">恢復購買</string>
<string name="ear_detection_description">取下時自動停止播放音訊,戴上時恢復播放。</string>
<string name="battery">電池</string>
<string name="battery_description">在應用程式與通知中查看準確的電池狀態。</string>
<string name="noise_control_description">直接從應用程式或快速設定中切換聽覺模式。</string>
<string name="advanced_device_settings">進階裝置設定</string>
<string name="advanced_device_settings_description">自訂個人化音量、自適應音訊、入睡時暫停媒體及其他輔助使用設定等功能。</string>
<string name="automatic_connection">自動連線</string>
<string name="automatic_connection_description">啟用並自訂自動連接至 AirPods 的功能。</string>
<string name="customizations_description">存取應用程式自訂功能,包括小工具中的手機電量、對話感知音量,以及更多即將推出的自訂功能。</string>
<string name="support_the_development">支援開發</string>
<string name="support_development_description">LibrePods 由單一開發者開發。升級有助於維持應用程式的運作。</string>
<string name="feature_availability_disclaimer">功能的可用性取決於你的 AirPods 型號與韌體版本。</string>
<string name="contact">聯絡</string>
<string name="email">電子郵件</string>
<string name="discord">Discord</string>
<string name="github_issues">GitHub Issues</string>
<string name="version_code">版本代碼</string>
<string name="build_type">建置類型</string>
<string name="no"></string>
<string name="yes"></string>
<string name="settings">設定</string>
<string name="requires_xposed">需要 Xposed</string>
<string name="bypass_compatibility_check">略過相容性檢查</string>
<string name="bypass_compatiblity_check_confirmation">你確定你的裝置原生支援或已啟用 Xposed 模組嗎?</string>
<string name="not_supported">不支援</string>
<string name="check_the_repository_for_more_info">請查看儲存庫以獲取更多資訊。</string>
</resources>

View File

@@ -140,11 +140,6 @@
<string name="widget">Widget</string>
<string name="show_phone_battery_in_widget">Show phone battery in widget</string>
<string name="show_phone_battery_in_widget_description">Display your phone\'s battery level in the widget alongside AirPods battery</string>
<string name="popup_animations">Popup Animations</string>
<string name="show_bottom_sheet_popup">Bottom sheet popup</string>
<string name="show_bottom_sheet_popup_description">Show the iOS-style modal popup at the bottom when AirPods connect.</string>
<string name="show_island_popup">Dynamic Island popup</string>
<string name="show_island_popup_description">Show the Dynamic Island-style popup at the top for connection and takeover events.</string>
<string name="conversational_awareness_volume">Conversational Awareness Volume</string>
<string name="quick_settings_tile">Quick Settings Tile</string>
<string name="open_dialog_for_controlling">Open dialog for controlling</string>
@@ -216,7 +211,7 @@
<string name="listening_mode_adaptive_description">Dynamically adjust external noise</string>
<string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string>
<string name="unlock_advanced_features">Unlock advanced features</string>
<string name="buy_price">Buy %s</string>
<string name="buy">Buy</string>
<string name="restore_purchases">Restore purchases</string>
<string name="ear_detection_description">Automatically stop playing audio when you take them off, and resume playback when you put them back on.</string>
<string name="battery">Battery</string>
@@ -241,42 +236,4 @@
<string name="yes">Yes</string>
<string name="settings">Settings</string>
<string name="requires_xposed">requires xposed</string>
<string name="bypass_compatibility_check">Bypass compatibility check</string>
<string name="bypass_compatiblity_check_confirmation">Are you sure your device is supported natively/you have Xposed module enabled?</string>
<string name="not_supported">Not supported</string>
<string name="check_the_repository_for_more_info">
Many devices are not supported due to limitations in the Android Bluetooth stack.
\nOn these devices, root access with an Xposed framework is required for full functionality.
\n\nThis limitation has been addressed in newer Android versions. The following device configurations can run the app natively:
\n• Google Pixel® running Android 16 March update and later with the lateset Play system update
\n• Google Pixel® running 17 Beta 3 and above
\n• OnePlus devices running OxygenOS 16 or later
\n• Oppo devices running ColorOS 16 or later
\n\nFor details, see the project documentation.
</string>
<string name="name_your_own_price">(Name your own price)</string>
<string name="compatibility_play_dialog_confirmation">
This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue.
</string>
<string name="type_ok_to_continue">Type "%s" to continue</string>
<string name="proceed">Proceed</string>
<string name="read_compatibility_requirements">I have read compatibility requirements.</string>
<string name="device_info">Device information</string>
<string name="build_id">Build ID</string>
<string name="manufacturer">Manufacturer</string>
<string name="free_features">Free features</string>
<string name="advanced_features">Advanced features</string>
<string name="digital_assistant_on_long_press">Digital Assistant on Long Press</string>
<string name="digital_assistant_on_long_press_description">Invoke Digital Assistant when long pressing the AirPods Pro stem.</string>
<string name="customizations_unavailable">Customizations unavailable. Connect your AirPods at least once to access.</string>
<string name="xposed_available">Xposed available</string>
<string name="app_enabled_in_xposed">App enabled in Xposed</string>
<string name="subject">Subject</string>
<string name="describe_your_issue">Describe your issue</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="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>

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods
import android.app.Application
class LibrePodsApplication: Application()

View File

@@ -0,0 +1,11 @@
package me.kavishdevar.librepods.data
class XposedRemotePrefImpl: XposedRemotePref {
override fun isAvailable(): Boolean { return false }
override fun getBoolean(key: String, def: Boolean): Boolean {
return false
}
override fun putBoolean(key: String, value: Boolean) { }
}

View File

@@ -0,0 +1,125 @@
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.ImageView
import androidx.core.net.toUri
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam
private const val TAG = "LibrePodsHook"
@SuppressLint("DiscouragedApi", "PrivateApi")
class KotlinModule: XposedModule() {
override fun onModuleLoaded(param: ModuleLoadedParam) {
log(Log.INFO, TAG, "module initialized at :: ${param.processName}")
log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion")
}
override fun onPackageLoaded(param: PackageLoadedParam) {
log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}")
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
try {
if (param.isFirstPackage) {
log(Log.INFO, TAG, "Loading native library for Bluetooth hook")
NativeBridge.setSdpHook(getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false))
System.loadLibrary("l2c_fcr_hook")
log(Log.INFO, TAG, "Native library loaded successfully")
}
} catch (e: Exception) {
log(Log.ERROR, TAG, "Failed to load native library: ${e.message}")
}
}
if (param.packageName == "com.google.android.settings") {
hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
if (param.packageName == "com.android.settings") {
hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
}
private fun hookSettingsController(param: PackageLoadedParam, className: String) {
log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = Class.forName(className, false, param.defaultClassLoader)
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
ImageView::class.java,
String::class.java
)
hook(updateIconMethod).intercept { chain ->
try {
log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}")
val imageView = chain.args[0] as? ImageView
val iconUri = chain.args[1] as? String
if (imageView == null || iconUri == null) {
return@intercept chain.proceed()
}
val uri = iconUri.toUri()
if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
return@intercept chain.proceed()
}
log(Log.INFO, TAG, "Handling AirPods icon URI: $uri")
Handler(Looper.getMainLooper()).post {
try {
val context = imageView.context
val packageName = uri.authority ?: return@post
val packageContext = context.createPackageContext(
packageName,
Context.CONTEXT_IGNORE_SECURITY
)
val resPath = uri.pathSegments
if (resPath.size >= 2 && resPath[0] == "drawable") {
val resourceName = resPath[1]
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", packageName
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
imageView.setImageDrawable(drawable)
imageView.alpha = 1.0f
log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName")
} else {
log(Log.ERROR, TAG, "Resource not found: $resourceName")
}
}
} catch (e: Exception) {
log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}")
}
}
null
} catch (e: Exception) {
log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}")
chain.proceed()
}
}
log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings")
} catch (e: Exception) {
log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}")
}
}
}
object NativeBridge {
external fun setSdpHook(enabled: Boolean)
}

View File

@@ -0,0 +1,28 @@
package me.kavishdevar.librepods.utils
import android.content.Context
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
object XposedServiceHolder {
var service: XposedService? = null
}
object XposedInitializer: XposedServiceHelper.OnServiceListener {
private var initialized = false
fun ensureInit(context: Context) {
if (initialized) return
initialized = true
XposedServiceHelper.registerListener(this)
}
override fun onServiceBind(service: XposedService) {
XposedServiceHolder.service = service
}
override fun onServiceDied(service: XposedService) {
XposedServiceHolder.service = null
}
}

View File

@@ -30,7 +30,7 @@
#include "l2c_fcr_hook.h"
extern "C" {
#include "xz.h"
#include "xz.h"
}
#define LOG_TAG "LibrePodsHook"
@@ -39,13 +39,13 @@ extern "C" {
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
tSDP_DI_RECORD*, uint32_t*) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("fake_l2c_fcr_chk_chan_modes called");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
@@ -55,13 +55,13 @@ uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
return 1;
}
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
tSDP_DI_RECORD* p_device_info,
uint32_t* p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord &&
enableSdpHook.load(std::memory_order_relaxed))
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
if (original_BTA_DmSetLocalDiRecord && enableSdpHook.load(std::memory_order_relaxed)) original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
@@ -70,15 +70,14 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t
p_device_info->vendor_id_source = 0x0001;
}
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
: BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
p_handle)
: BTA_FAILURE;
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE;
}
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
static bool decompressXZ(
const uint8_t* input,
size_t input_size,
std::vector<uint8_t>& output) {
LOGI("decompressXZ called with input_size: %zu", input_size);
@@ -87,7 +86,7 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
xz_crc64_init();
#endif
struct xz_dec *dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
if (!dec) {
LOGE("decompressXZ: xz_dec_init failed");
return false;
@@ -107,8 +106,7 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
LOGI("decompressXZ: entering decompression loop");
while (true) {
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos,
buf.out_pos);
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos, buf.out_pos);
enum xz_ret ret = xz_dec_run(dec, &buf);
LOGI("decompressXZ: xz_dec_run returned %d", ret);
@@ -137,10 +135,10 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
return true;
}
static bool getLibraryPath(const char *name, std::string &out) {
static bool getLibraryPath(const char* name, std::string& out) {
LOGI("getLibraryPath called with name: %s", name);
FILE *fp = fopen("/proc/self/maps", "r");
FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("getLibraryPath: fopen failed");
return false;
@@ -152,7 +150,7 @@ static bool getLibraryPath(const char *name, std::string &out) {
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) {
LOGI("getLibraryPath: found line containing %s", name);
char *path = strchr(line, '/');
char* path = strchr(line, '/');
if (path) {
out = path;
out.erase(out.find('\n'));
@@ -168,10 +166,10 @@ static bool getLibraryPath(const char *name, std::string &out) {
return false;
}
static uintptr_t getModuleBase(const char *name) {
static uintptr_t getModuleBase(const char* name) {
LOGI("getModuleBase called with name: %s", name);
FILE *fp = fopen("/proc/self/maps", "r");
FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("getModuleBase: fopen failed");
return 0;
@@ -194,78 +192,26 @@ static uintptr_t getModuleBase(const char *name) {
return base;
}
static uint64_t
findSymbolOffsetDynsym(const std::vector<uint8_t> &elf, const char *symbol_substring) {
LOGI("findSymbolOffsetDynsym called with %s", symbol_substring);
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
elf.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
elf.data() + shdr[eh->e_shstrndx].sh_offset);
const Elf64_Shdr *dynsym = nullptr;
const Elf64_Shdr *dynstr = nullptr;
for (int i = 0; i < eh->e_shnum; ++i) {
const char *secname = shstr + shdr[i].sh_name;
if (!strcmp(secname, ".dynsym"))
dynsym = &shdr[i];
if (!strcmp(secname, ".dynstr"))
dynstr = &shdr[i];
}
if (!dynsym || !dynstr) {
LOGE("findSymbolOffsetDynsym: dynsym or dynstr not found");
return 0;
}
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
elf.data() + dynsym->sh_offset);
const char *strings = reinterpret_cast<const char *>(
elf.data() + dynstr->sh_offset);
size_t count = dynsym->sh_size / sizeof(Elf64_Sym);
LOGI("findSymbolOffsetDynsym: scanning %zu symbols", count);
for (size_t i = 0; i < count; ++i) {
const char *name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffsetDynsym: matched %s @ 0x%lx", name,
(unsigned long) symbols[i].st_value);
return symbols[i].st_value;
}
}
LOGI("findSymbolOffsetDynsym: no match for %s", symbol_substring);
return 0;
}
static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *symbol_substring) {
static uint64_t findSymbolOffset(
const std::vector<uint8_t>& elf,
const char* symbol_substring) {
LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring);
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data());
auto* shdr = reinterpret_cast<const Elf64_Shdr*>(
elf.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
elf.data() + shdr[eh->e_shstrndx].sh_offset);
const char* shstr =
reinterpret_cast<const char*>(
elf.data() + shdr[eh->e_shstrndx].sh_offset);
const Elf64_Shdr *symtab = nullptr;
const Elf64_Shdr *strtab = nullptr;
const Elf64_Shdr* symtab = nullptr;
const Elf64_Shdr* strtab = nullptr;
LOGI("findSymbolOffset: parsing ELF sections");
for (int i = 0; i < eh->e_shnum; ++i) {
const char *secname = shstr + shdr[i].sh_name;
const char* secname = shstr + shdr[i].sh_name;
if (!strcmp(secname, ".symtab"))
symtab = &shdr[i];
if (!strcmp(secname, ".strtab"))
@@ -278,22 +224,23 @@ static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *sy
}
LOGI("findSymbolOffset: found symtab and strtab");
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
auto* symbols = reinterpret_cast<const Elf64_Sym*>(
elf.data() + symtab->sh_offset);
const char *strings = reinterpret_cast<const char *>(
elf.data() + strtab->sh_offset);
const char* strings =
reinterpret_cast<const char*>(
elf.data() + strtab->sh_offset);
size_t count = symtab->sh_size / sizeof(Elf64_Sym);
LOGI("findSymbolOffset: scanning %zu symbols", count);
for (size_t i = 0; i < count; ++i) {
const char *name = strings + symbols[i].st_name;
const char* name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
if (strstr(name, symbol_substring) &&
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name,
(unsigned long) symbols[i].st_value);
LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name, (unsigned long)symbols[i].st_value);
return symbols[i].st_value;
}
@@ -303,7 +250,7 @@ static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *sy
return 0;
}
static bool hookLibrary(const char *libname) {
static bool hookLibrary(const char* libname) {
LOGI("hookLibrary called with libname: %s", libname);
if (!hook_func) {
@@ -330,78 +277,89 @@ static bool hookLibrary(const char *libname) {
close(fd);
return false;
}
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
LOGI("hookLibrary: opened file, size: %lld", (long long)st.st_size);
std::vector<uint8_t> file(st.st_size);
read(fd, file.data(), st.st_size);
close(fd);
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
auto* eh = reinterpret_cast<Elf64_Ehdr*>(file.data());
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
file.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
file.data() + shdr[eh->e_shstrndx].sh_offset);
uint64_t chk_offset = 0;
uint64_t sdp_offset = 0;
const char* shstr =
reinterpret_cast<const char*>(
file.data() + shdr[eh->e_shstrndx].sh_offset);
LOGI("hookLibrary: parsing ELF header and sections");
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> compressed(
file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed;
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
if (!decompressXZ(
compressed.data(),
compressed.size(),
decompressed)) {
LOGE("hookLibrary: decompressXZ failed");
return false;
}
LOGI("hookLibrary: decompressed debug data, size: %zu", decompressed.size());
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
uintptr_t base = getModuleBase(libname);
if (!base) {
LOGE("hookLibrary: getModuleBase failed");
return false;
}
LOGI("hookLibrary: module base: 0x%lx", base);
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
} else {
LOGE("debugdata decompress failed");
uint64_t chk_offset =
findSymbolOffset(decompressed,
"l2c_fcr_chk_chan_modes");
uint64_t sdp_offset =
findSymbolOffset(decompressed,
"BTA_DmSetLocalDiRecord");
LOGI("hookLibrary: chk_offset: 0x%lx, sdp_offset: 0x%lx", chk_offset, sdp_offset);
if (chk_offset) {
void* target =
reinterpret_cast<void*>(base + chk_offset);
hook_func(target,
(void*)fake_l2c_fcr_chk_chan_modes,
(void**)&original_l2c_fcr_chk_chan_modes);
LOGI("hookLibrary: hooked l2c_fcr_chk_chan_modes");
}
break;
if (sdp_offset) {
void* target =
reinterpret_cast<void*>(base + sdp_offset);
hook_func(target,
(void*)fake_BTA_DmSetLocalDiRecord,
(void**)&original_BTA_DmSetLocalDiRecord);
LOGI("hookLibrary: hooked BTA_DmSetLocalDiRecord");
}
return true;
}
}
if (!chk_offset) {
LOGI("fallback dynsym chk");
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
}
if (!sdp_offset) {
LOGI("fallback dynsym sdp");
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
}
uintptr_t base = getModuleBase(libname);
if (!base) {
LOGE("hookLibrary: getModuleBase failed");
return false;
}
if (chk_offset) {
void *target = reinterpret_cast<void *>(base + chk_offset);
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
(void **) &original_l2c_fcr_chk_chan_modes);
LOGI("hooked chk");
}
if (sdp_offset) {
void *target = reinterpret_cast<void *>(base + sdp_offset);
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
(void **) &original_BTA_DmSetLocalDiRecord);
LOGI("hooked sdp");
}
return chk_offset || sdp_offset;
LOGI("hookLibrary: failed for %s", libname);
return false;
}
static void on_library_loaded(const char *name, void *) {
static void on_library_loaded(const char* name, void*) {
LOGI("on_library_loaded called with name: %s", name);
if (strstr(name, "libbluetooth_jni.so")) {
@@ -415,19 +373,20 @@ static void on_library_loaded(const char *name, void *) {
}
}
extern "C" [[gnu::visibility("default")]]
extern "C"
[[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
hook_func = (HookFunType)entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded;
}
extern "C" JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
jboolean enable) {
extern "C"
JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(
JNIEnv*, jobject thiz, jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed);

View File

@@ -0,0 +1,21 @@
package me.kavishdevar.librepods
import android.app.Application
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import me.kavishdevar.librepods.utils.XposedServiceHolder
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener {
override fun onCreate() {
super.onCreate()
XposedServiceHelper.registerListener(this)
}
override fun onServiceBind(p0: XposedService) {
XposedServiceHolder.service = p0
}
override fun onServiceDied(p0: XposedService) {
XposedServiceHolder.service = null
}
}

View File

@@ -20,7 +20,6 @@ class KotlinModule: XposedModule() {
log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion")
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
override fun onPackageLoaded(param: PackageLoadedParam) {
log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}")
@@ -28,36 +27,8 @@ class KotlinModule: XposedModule() {
log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
try {
if (param.isFirstPackage) {
val abi = android.os.Build.SUPPORTED_ABIS.first()
val soName = "libl2c_fcr_hook.so"
val candidates = buildList {
add("${moduleApplicationInfo.sourceDir}!/lib/$abi/$soName")
moduleApplicationInfo.splitSourceDirs?.forEach { split ->
add("$split!/lib/$abi/$soName")
}
}
var loaded = false
for (path in candidates) {
try {
log(Log.INFO, TAG, "Trying to load native lib from $path")
System.load(path)
log(Log.INFO, TAG, "Loaded native lib from $path")
loaded = true
break
} catch (e: Throwable) {
log(Log.WARN, TAG, "Failed to load from $path: ${e.message}")
}
}
if (!loaded) {
log(Log.ERROR, TAG, "Could not load $soName from base or splits")
return
}
log(Log.INFO, TAG, "Loading native library for Bluetooth hook")
System.loadLibrary("l2c_fcr_hook")
val remotePrefValue = getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false)
log(Log.INFO, TAG, "sdp hook enabled (remote pref): $remotePrefValue")
NativeBridge.setSdpHook(remotePrefValue)

View File

@@ -1,25 +1,23 @@
[versions]
accompanistPermissions = "0.37.3"
agp = "9.1.1"
kotlin = "2.3.21"
agp = "9.1.0"
kotlin = "2.3.20"
coreKtx = "1.18.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.00"
composeBom = "2026.03.01"
annotations = "26.1.0"
navigationCompose = "2.9.8"
navigationCompose = "2.9.7"
constraintlayout = "2.2.1"
haze = "1.7.2"
hazeMaterials = "1.7.2"
dynamicanimation = "1.1.0"
aboutLibraries = "14.2.0"
aboutLibraries = "14.0.1"
materialIconsCore = "1.7.8"
backdrop = "2.0.0-alpha03"
billing = "8.3.0"
hilt = "2.59.2"
xposed = "101.0.0"
lifecycleProcess = "2.10.0"
play = "2.0.2"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -49,9 +47,6 @@ hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
play-review = { group = "com.google.android.play", name="review", version.ref = "play" }
play-review-ktx = { group = "com.google.android.play", name="review-ktx", version.ref = "play" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

11
build-magisk-module.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -eux
cd root-module
rm -f ../btl2capfix.zip
# COPYFILE_DISABLE env is a macOS fix to avoid parasitic files in ZIPs: https://superuser.com/a/260264
export COPYFILE_DISABLE=1
curl -L -o ./radare2-5.9.9-android-aarch64.tar.gz "https://github.com/devnoname120/radare2/releases/download/5.9.8-android-aln/radare2-5.9.9-android-aarch64-aln.tar.gz"
zip -r ../btl2capfix.zip . -x \*.DS_Store \*__MACOSX \*DEBIAN ._\* .gitignore

View File

@@ -16,53 +16,53 @@ Bytes that are not used are set to `0x00`. From what I've observed, the `data3`
## Identifiers and details
| Command identifier | Description |
| ------------------ | ---------------------------------------------- |
| 0x01 | Mic Mode |
| 0x05 | Button Send Mode |
| 0x06 | Owns connection |
| 0x0A | Ear Detection |
| 0x12 | VoiceTrigger for Siri |
| 0x14 | SingleClickMode |
| 0x15 | DoubleClickMode |
| 0x16 | ClickHoldMode |
| 0x17 | DoubleClickInterval |
| 0x18 | ClickHoldInterval |
| 0x1A | ListeningModeConfigs |
| 0x1B | OneBudANCMode |
| 0x1C | CrownRotationDirection |
| 0x0D | ListeningMode |
| 0x1E | AutoAnswerMode |
| 0x1F | Chime Volume |
| 0x20 | Connect Automatically |
| 0x23 | VolumeSwipeInterval |
| 0x24 | Call Management Config |
| 0x25 | VolumeSwipeMode |
| 0x26 | Adaptive Volume Config |
| 0x27 | Software Mute config |
| 0x28 | Conversation Detect config |
| 0x29 | SSL |
| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled |
| 0x2E | AutoANC Strength |
| 0x2F | HPS Gain Swipe |
| 0x30 | HRM enable/disable state |
| 0x31 | In Case Tone config |
| 0x32 | Siri Multitone config |
| 0x33 | Hearing Assist config |
| 0x34 | Allow Off Option for Listening Mode config |
| 0x35 | Sleep Detection config |
| 0x36 | Allow Auto Connect |
| 0x37 | PPE Toggle config |
| 0x38 | Personal Protective Equipment Cap Level config |
| 0x39 | Raw Gestures config |
| 0x3A | Temporary Pairing Config |
| 0x3B | Dynamic End of Charge config |
| 0x3C | System Siri message config |
| 0x3D | Hearing Aid Generic config |
| 0x3E | Uplink EQ Bud config |
| 0x3F | Uplink EQ Source config |
| 0x40 | In Case Tone Volume |
| 0x41 | Disable Button Input config |
| Command identifier | Description |
|--------------|---------------------|
| 0x01 | Mic Mode |
| 0x05 | Button Send Mode |
| 0x06 | Owns connection |
| 0x0A | Ear Detection |
| 0x12 | VoiceTrigger for Siri |
| 0x14 | SingleClickMode |
| 0x15 | DoubleClickMode |
| 0x16 | ClickHoldMode |
| 0x17 | DoubleClickInterval |
| 0x18 | ClickHoldInterval |
| 0x1A | ListeningModeConfigs |
| 0x1B | OneBudANCMode |
| 0x1C | CrownRotationDirection |
| 0x0D | ListeningMode |
| 0x1E | AutoAnswerMode |
| 0x1F | Chime Volume |
| 0x20 | Connect Automatically |
| 0x23 | VolumeSwipeInterval |
| 0x24 | Call Management Config |
| 0x25 | VolumeSwipeMode |
| 0x26 | Adaptive Volume Config |
| 0x27 | Software Mute config |
| 0x28 | Conversation Detect config |
| 0x29 | SSL |
| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled |
| 0x2E | AutoANC Strength |
| 0x2F | HPS Gain Swipe |
| 0x30 | HRM enable/disable state |
| 0x31 | In Case Tone config |
| 0x32 | Siri Multitone config |
| 0x33 | Hearing Assist config |
| 0x34 | Allow Off Option for Listening Mode config |
| 0x35 | Sleep Detection config |
| 0x36 | Allow Auto Connect |
| 0x37 | PPE Toggle config |
| 0x38 | Personal Protective Equipment Cap Level config |
| 0x39 | Raw Gestures config |
| 0x3A | Temporary Pairing Config |
| 0x3B | Dynamic End of Charge config |
| 0x3C | System Siri message config |
| 0x3D | Hearing Aid Generic config |
| 0x3E | Uplink EQ Bud config |
| 0x3F | Uplink EQ Source config |
| 0x40 | In Case Tone Volume |
| 0x41 | Disable Button Input config |
## Command Details

View File

@@ -1,26 +0,0 @@
---
opcode: 0x001D
title: Device Information
description: Information about AirPods, such as model, firmware version, and serial number. This can not be requested from the accessory; it is only sent by the accessory to the host upon connection.
---
## Device information
The device information packet is sent by the accessory to the host upon connection. It contains various details about the AirPods, including model number, software version, and serial number.
Each `null` indicates the start of a new string field.
The data is in this order:
- Name
- Model number
- Manufacturer (always "Apple Inc.")
- Serial number
- Version 1
- Version 2
- Hardware revision (?) (I have `1.0.0`)
- Updater app version (?) (I have `com.apple.accessory.updater.app.71`)
- Serial number (Left Bud)
- Serial number (Right Bud)
- Version (?) (I have `8454371`)
- A few more bytes, I don't know what they are

View File

@@ -1,33 +0,0 @@
# AACP opcodes
AACP (Apple Accessory Communication Protocol) uses various opcodes to define different types of actions and commands. Each opcode is a 16-bit integer that specifies the kind of operation being performed. The opcode is sent in little-endian format as part of the AACP packet structure.
| Opcode (Hex) | Destination | Description |
| ------------ | ----------- | ------------------------------------------------------------------ |
| 0x0001 | Accessory | Unknown |
| 0x0004 | Host | [Battery report](/docs/battery_report.md) |
| 0x0006 | Host | [Ear detection](/docs/ear-detection_report.md) |
| 0x0009 | Both | [Control commands](/docs/control_commands.md) |
| 0x000D | Accessory | [Audio source req](/docs/audio-source.md) |
| 0x000E | Host | [Audio source resp](/docs/audio-source.md) |
| 0x000F | Accessory | [Notification register](/docs/notification-register.md) |
| 0x0010 | Accessory | [Smart routing relay](/docs/smart-routing-relay.md#send) |
| 0x0011 | Host | [Smart routing response](/docs/smart-routing-relay.md#receive) |
| 0x0014 | Accessory | Send connected device MAC |
| 0x0017 | Both | Multiple things - undocumented |
| 0x0019 | Host | [Stem press](/docs/stem-press.md) |
| 0x001B | Accessory | [Timestamp](/docs/timestamp.md) |
| 0x001D | Host | [Device Information](/docs/device-info.md) |
| 0x001E | Accessory | [Rename device](/docs/rename.md) |
| 0x0022 | Accessory | Unknown |
| 0x0029 | Accessory | [Host capabilities](/docs/host-capabilities.md#another-opcode) (?) |
| 0x002B | Host | Paired devices (?) |
| 0x002D | Accessory | [List of connected dev. req](/docs/connected-devices.md#send) |
| 0x002E | Host | [List of connected devices](/docs/connected-devices.md#receive) |
| 0x0030 | Accessory | [BLE keys req](/docs/ble-keys.md) |
| 0x0031 | Host | [BLE keys response](/docs/ble-keys.md) |
| 0x004B | Host | [Conversation awareness](/docs/conversational-awareness.md) |
| 0x004D | Accessory | [Host capabilities](/docs/host-capabilities.md) |
| 0x004F | Both | Information req/res (doesn't work, even with apple's DID) |
| 0x0053 | Both | [EQ data](/docs/eq.md) |

60
head-tracking/README.md Normal file
View File

@@ -0,0 +1,60 @@
# AirPods Head Tracking Visualizer
This implements head tracking with AirPods by gathering sensor data over l2cap, processing orientation and acceleration values, and detecting head gestures. The codebase is split into the following components:
# How to use
Connect your airpods and change the mac address in `plot.py` to your airpods mac address. Then run the following command to start the program.
```bash
python plot.py
```
Alternatively, you can directly run the `gestures.py` to just detect gestures.
```bash
python gestures.py
```
- **Connection and Data Collection**
The project uses a custom ConnectionManager (imported in multiple files) to connect via Bluetooth to AirPods. Once connected, sensor packets are received in raw hex format. An AirPodsTracker class (in `plot.py`) handles the start/stop of tracking, logging of raw data, and parsing of packets into useful fields.
- **Orientation Calculation and Visualization**
The `HeadOrientation` class (in `head_orientation.py`) is responsible for:
- **Calibration:**
A set number of samples (default 10) are collected to calculate the neutral (baseline) values for the sensors. For example:
`o1_neutral = np.mean(samples[:, 0])`
- **Calculating Angles:**
For each new packet, the raw orientation values are normalized by subtracting the neutral baseline. Then:
- **Pitch** is computed as:
```
pitch = (o2_norm + o3_norm) / 2 / 32000 * 180
```
This averages the deviations from neutral, scales the result to degrees (assuming a sensor range around 32000), thus giving a smooth estimation of up/down tilt.
- **Yaw** is computed as:
```
yaw = (o2_norm - o3_norm) / 2 / 32000 * 180
```
Here, the difference between the two sensor axes is used to detect left/right rotation.
- **ASCII Visualization:**
Based on the calculated pitch and yaw, an ASCII art "face" is generated. The algorithm rotates points on a circle using simple trigonometric formulas (with scaling factors based on sensor depth) to build an approximate visual representation of head orientation.
- **Live Plotting and Interactive Commands**
The code offers both terminal-based plotting and graphical plotting via matplotlib. The AirPodsTracker manages live plotting by maintaining a buffer of recent packets. When in terminal mode, the code uses libraries like `asciichartpy` and `drawille` to render charts; in graphical mode, it creates live-updating plots.
- **Gesture Detection**
The `GestureDetector` class (in `gestures.py`) processes the head tracking data to detect nodding ("Yes") or head shaking ("No"):
- **Smoothing:**
Raw horizontal and vertical sensor data undergo moving-average smoothing using small fixed-size buffers. This reduces noise and provides a steadier signal.
- **Peak and Trough Detection:**
The code monitors small sections (e.g. the last 4 values) to compute variance and dynamically determine thresholds for direction changes. When a significant reversal (e.g. from increasing to decreasing) is detected that surpasses the dynamic threshold value (derived partly from a fixed threshold and variance), a peak or trough is recorded.
- **Rhythm Consistency:**
Time intervals between detected peaks are captured. The consistency of these intervals (by comparing them to their mean and computing relative variance) is used to evaluate whether the movement is rhythmic—a trait of intentional gestures.
- **Confidence Calculation:**
Multiple factors are considered:
- **Amplitude Factor:** Compares the average detected peak amplitude with a constant (like 600) to provide a normalized measure.
- **Rhythm Factor:** Derived from the consistency of the time intervals of the peaks.
- **Alternation Factor:** Verifies that the signal alternates (for instance, switching between positive and negative values).
- **Isolation Factor:** Checks that movement on the target axis (vertical for nodding, horizontal for shaking) dominates over the non-target axis.
A weighted sum of these factors forms a confidence score which, if above a predefined threshold (e.g. 0.7), confirms a detected gesture.

29
head-tracking/colors.py Normal file
View File

@@ -0,0 +1,29 @@
import logging
from logging import Formatter, LogRecord
from typing import Dict
class Colors:
RESET: str = "\033[0m"
BOLD: str = "\033[1m"
RED: str = "\033[91m"
GREEN: str = "\033[92m"
YELLOW: str = "\033[93m"
BLUE: str = "\033[94m"
MAGENTA: str = "\033[95m"
CYAN: str = "\033[96m"
WHITE: str = "\033[97m"
BG_BLACK: str = "\033[40m"
class ColorFormatter(Formatter):
FORMATS: Dict[int, str] = {
logging.DEBUG: f"{Colors.BLUE}[%(levelname)s] %(message)s{Colors.RESET}",
logging.INFO: f"{Colors.GREEN}%(message)s{Colors.RESET}",
logging.WARNING: f"{Colors.YELLOW}%(message)s{Colors.RESET}",
logging.ERROR: f"{Colors.RED}[%(levelname)s] %(message)s{Colors.RESET}",
logging.CRITICAL: f"{Colors.RED}{Colors.BOLD}[%(levelname)s] %(message)s{Colors.RESET}"
}
def format(self, record: LogRecord) -> str:
log_fmt: str = self.FORMATS.get(record.levelno)
formatter: Formatter = Formatter(log_fmt, datefmt="%H:%M:%S")
return formatter.format(record)

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