Compare commits

..

10 Commits

Author SHA1 Message Date
Kavish Devar 04ef891c4b disable DI hook and dependant functions 2026-03-29 16:28:45 +05:30
Kavish Devar 555a000def add A16QPR3 constructor for ATT
i really need to clean this up
2026-03-29 16:22:16 +05:30
Kavish Devar 45f53d5cd4 actually bring back DI hook 2026-03-29 16:12:12 +05:30
Kavish Devar 1425a7d7cf bump version 2026-03-29 16:07:39 +05:30
Kavish Devar e8204a7750 add A16QPR3 constructor
it's almost as if google is doing this deliberately doing this
2026-03-29 16:07:23 +05:30
Kavish Devar 5fbfda6115 add back DI hook 2026-03-29 16:04:50 +05:30
Kavish Devar a6a284c2ec android: disable DI hook
for CI
2026-03-18 08:52:28 +05:30
Kavish Devar 5c0d9b5096 remove radare2 onboarding
mostly ai generated, but I've tested it.
2026-02-24 19:01:31 +05:30
Kavish Devar 752b53aecd Merge branch 'main' into android/testing 2026-02-24 15:58:44 +05:30
Kavish Devar 3e4d401223 call original fcr_chk_chan to set preferred mode to BASIC 2025-12-18 00:43:32 +05:30
236 changed files with 17207 additions and 20460 deletions
@@ -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).
@@ -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
@@ -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
+67 -179
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
+1 -1
View File
@@ -1,4 +1,4 @@
name: Linux CI (Rust)
name: Linux Build & Release
on:
push:
+6 -1
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*
+164
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).
+72 -178
View File
@@ -1,228 +1,122 @@
>[!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.
---
![LibrePods Banner](./imgs/banner.png)
<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>
## What is LibrePods?
<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 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.
# What is LibrePods?
## Device Compatibility
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.
| 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 |
# Feature availability
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).
| 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) | 🔴 | 🔴 |
## Key Features
| 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 |
- **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!
## Find My
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
The following features related to Find My are planned, but require further RE and might need root on Android:
## Platform Support
- Add your AirPods to the Find My network
- Play sound through charging case to find it
- Notify when leaving behind
- Toggle case charging sounds
### 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)
## Spatial Audio
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
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.
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
### Android
## 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.
#### Screenshots
## High quality two-way audio
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
| | | |
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
| ![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) |
Since this needs deeper integration with audio on Android, it will most likely need root.
# Installation
here's a very unprofessional demo video
- [**Android**](/android/README.md)
- [**Linux**](/linux/README.md)
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
# VendorID Spoofing
#### Root Requirement
If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
> [!CAUTION]
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. DO NOT leave a +1 comment - use the +1 button in the top right of the page next to the "Hotlists" field. Leaving +1 comment spam makes it impossible for developers to engage in the necessary technical discussion to implement this fix, and will disincentivize the responsible Google developers from engaging. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+.
>
> There are **no exceptions** to the root requirement until Google/your OEM figures out a fix.
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
## 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
### Multi-device Connectivity
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
## Accessibility Settings and Hearing Aid
### Accessibility Settings and Hearing Aid
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
# Protocol and Reverse Engineering
#### A few notes
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)
- 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.
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!
- 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.
# Use of AI
- 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.
## Android app
- 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.
These parts of the app were completely AI-generated:
- Head Gestures - all of it, including logic and the UI
- The offset setup with r2+the xposed module (both versions)
- Troubleshooter and LogCollector
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
## Linux (rewrite)
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
# Supporters
## Supporters
A huge thank you to everyone supporting the project!
- @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/))
# Star History
## Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
<picture>
-67
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.
+24 -177
View File
@@ -1,106 +1,42 @@
import java.util.Properties
val appVersionName = "1.0.0-rc1"
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
val localPropsFile = rootProject.file("local.properties")
val props = Properties().apply {
if (localPropsFile.exists()) {
load(localPropsFile.inputStream())
}
}
val releaseSigningAvailable = listOf(
"RELEASE_STORE_FILE",
"RELEASE_STORE_PASSWORD",
"RELEASE_KEY_ALIAS",
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
kotlin {
compilerOptions {
optIn.add(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
)
}
}
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
}
}
}
namespace = "me.kavishdevar.librepods"
compileSdk = 37
compileSdk = 36
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
versionCode = 61
versionName = appVersionName
minSdk = 33
targetSdk = 36
versionCode = 10
versionName = "0.2.0-alpha.2"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
isMinifyEnabled = false
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
}
}
debug {
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}
versionNameSuffix = "-debug"
defaultConfig {
minSdk = 33
}
}
}
productFlavors {
create("foss") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "false")
}
create("play") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
minSdk = 36
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
viewBinding = true
buildConfig = true
}
androidResources {
generateLocaleConfig = true
@@ -113,23 +49,18 @@ android {
}
sourceSets {
getByName("main") {
res.directories += "src/main/res-apple"
res.srcDirs("src/main/res", "src/main/res-apple")
}
}
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.hiddenapibypass)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
@@ -141,105 +72,21 @@ dependencies {
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
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)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigationevent)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
compileOnly(files("libs/libxposed-api-100.aar"))
debugImplementation(files("libs/backdrop-debug.aar"))
releaseImplementation(files("libs/backdrop-release.aar"))
}
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.
Binary file not shown.
Binary file not shown.
+1 -3
View File
@@ -18,6 +18,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
#-renamesourcefileattribute SourceFile
+22 -30
View File
@@ -17,15 +17,6 @@
<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" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
@@ -35,15 +26,15 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
<!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<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"
android:maxSdkVersion="30" />
<application
android:name=".LibrePodsApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
@@ -54,9 +45,10 @@
android:supportsRtl="true"
android:theme="@style/Theme.LibrePods"
android:description="@string/app_description"
tools:ignore="UnusedAttribute" >
tools:ignore="UnusedAttribute"
tools:targetApi="31">
<receiver
android:name=".presentation.widgets.NoiseControlWidget"
android:name=".widgets.NoiseControlWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -68,7 +60,7 @@
</receiver>
<receiver
android:name=".presentation.widgets.BatteryWidget"
android:name=".widgets.BatteryWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -122,17 +114,17 @@
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!-- <service-->
<!-- android:name=".services.AppListenerService"-->
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
<!-- </intent-filter>-->
<!-- <meta-data-->
<!-- android:name="android.accessibilityservice"-->
<!-- android:resource="@xml/app_listener_service_config" />-->
<!-- </service>-->
<service
android:name=".services.AppListenerService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/app_listener_service_config" />
</service>
<receiver
android:name=".receivers.BootReceiver"
android:enabled="true"
+1 -24
View File
@@ -3,28 +3,6 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
)
target_compile_options(bluetooth_socket PRIVATE
-O2
-fvisibility=hidden
)
target_link_options(bluetooth_socket PRIVATE
-Wl,--strip-all
-Wl,--gc-sections
)
target_link_libraries(bluetooth_socket
android
log
)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
@@ -53,5 +31,4 @@ target_compile_definitions(l2c_fcr_hook PRIVATE
target_link_libraries(l2c_fcr_hook
android
log
)
log)
@@ -1,68 +0,0 @@
#include <jni.h>
#include <pthread.h>
#include <array>
#include <string>
static JavaVM* gVm = nullptr;
template<size_t N>
constexpr auto encryptString(const char (&str)[N], char key) {
std::array<char, N> encrypted{};
for (size_t i = 0; i < N; i++) {
encrypted[i] = str[i] ^ key;
}
return encrypted;
}
template<size_t N>
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
std::string result(N - 1, '\0');
for (size_t i = 0; i < N - 1; i++) {
result[i] = encrypted[i] ^ key;
}
return result;
}
#define ENC(str) encryptString(str, 0x47)
#define DEC(arr) decryptString(arr, 0x47).c_str()
__attribute__((visibility("hidden")))
static JavaVM* getVm() { return gVm; }
__attribute__((visibility("default")))
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gVm = vm;
auto fn = [](void*) -> void* {
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
constexpr auto c2 = ENC("getRuntime");
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
constexpr auto c4 = ENC("setHiddenApiExemptions");
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
jclass vmRuntime = env->FindClass(DEC(c1));
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
jobjectArray prefixes = env->NewObjectArray(
2, env->FindClass(DEC(c6)), nullptr);
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
env->CallVoidMethod(runtime, setExemptions, prefixes);
getVm()->DetachCurrentThread();
return nullptr;
};
pthread_t t;
pthread_create(&t, nullptr, fn, nullptr);
pthread_join(t, nullptr);
return JNI_VERSION_1_6;
}
+109 -220
View File
@@ -24,75 +24,62 @@
#include <unistd.h>
#include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h"
extern "C" {
#include "xz.h"
#include "xz.h"
}
#define LOG_TAG "LibrePodsHook"
#define LOG_TAG "LibrePods"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = 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 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) {
LOGI("fake_l2c_fcr_chk_chan_modes called");
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
LOGI("Original returned %d, forcing 1", orig);
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);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
LOGI("BTA_DmSetLocalDiRecord hooked");
if (p_device_info) {
p_device_info->vendor = 0x004C;
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;
if (original_BTA_DmSetLocalDiRecord)
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
return BTA_FAILURE;
}
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);
static bool decompressXZ(
const uint8_t* input,
size_t input_size,
std::vector<uint8_t>& output) {
xz_crc32_init();
#ifdef XZ_USE_CRC64
xz_crc64_init();
#endif
struct xz_dec *dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
if (!dec) {
LOGE("decompressXZ: xz_dec_init failed");
return false;
}
LOGI("decompressXZ: xz_dec_init succeeded");
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
if (!dec) return false;
struct xz_buf buf{};
buf.in = input;
@@ -105,26 +92,19 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
buf.out_pos = 0;
buf.out_size = output.size();
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);
enum xz_ret ret = xz_dec_run(dec, &buf);
LOGI("decompressXZ: xz_dec_run returned %d", ret);
if (ret == XZ_STREAM_END)
break;
if (ret != XZ_OK) {
LOGE("decompressXZ: xz_dec_run error");
xz_dec_end(dec);
return false;
}
if (buf.out_pos == buf.out_size) {
size_t old = output.size();
LOGI("decompressXZ: resizing output to %zu", old * 2);
output.resize(old * 2);
buf.out = output.data();
buf.out_size = output.size();
@@ -133,30 +113,21 @@ static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<ui
output.resize(buf.out_pos);
xz_dec_end(dec);
LOGI("decompressXZ: decompression successful, output size: %zu", output.size());
return true;
}
static bool getLibraryPath(const char *name, std::string &out) {
LOGI("getLibraryPath called with name: %s", name);
FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("getLibraryPath: fopen failed");
return false;
}
static bool getLibraryPath(const char* name, std::string& out) {
FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) return false;
char line[1024];
LOGI("getLibraryPath: scanning /proc/self/maps");
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'));
LOGI("getLibraryPath: path found: %s", out.c_str());
fclose(fp);
return true;
}
@@ -164,147 +135,80 @@ static bool getLibraryPath(const char *name, std::string &out) {
}
fclose(fp);
LOGI("getLibraryPath: failed to find path for %s", name);
return false;
}
static uintptr_t getModuleBase(const char *name) {
LOGI("getModuleBase called with name: %s", name);
FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("getModuleBase: fopen failed");
return 0;
}
static uintptr_t getModuleBase(const char* name) {
FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) return 0;
char line[1024];
uintptr_t base = 0;
LOGI("getModuleBase: scanning /proc/self/maps");
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) {
base = strtoull(line, nullptr, 16);
LOGI("getModuleBase: found base at 0x%lx", base);
break;
}
}
fclose(fp);
LOGI("getModuleBase: failed to find base for %s", name);
return base;
}
static uint64_t
findSymbolOffsetDynsym(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("findSymbolOffsetDynsym called with %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 *dynsym = nullptr;
const Elf64_Shdr *dynstr = nullptr;
const Elf64_Shdr* symtab = nullptr;
const Elf64_Shdr* strtab = 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) {
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 *>(
elf.data() + eh->e_shoff);
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;
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"))
strtab = &shdr[i];
}
if (!symtab || !strtab) {
LOGE("findSymbolOffset: symtab or strtab not found");
if (!symtab || !strtab)
return 0;
}
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("Resolved %s at 0x%lx",
name,
(unsigned long)symbols[i].st_value);
return symbols[i].st_value;
}
}
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
return 0;
}
static bool hookLibrary(const char *libname) {
LOGI("hookLibrary called with libname: %s", libname);
static bool hookLibrary(const char* libname) {
if (!hook_func) {
LOGE("hook_func not initialized");
@@ -316,93 +220,85 @@ static bool hookLibrary(const char *libname) {
LOGE("Failed to locate %s", libname);
return false;
}
LOGI("hookLibrary: located path: %s", path.c_str());
int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) {
LOGE("hookLibrary: open failed");
return false;
}
if (fd < 0) return false;
struct stat st{};
if (fstat(fd, &st) != 0) {
LOGE("hookLibrary: fstat failed");
close(fd);
return false;
}
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);
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);
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
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))
return false;
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
uintptr_t base = getModuleBase(libname);
if (!base) return false;
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");
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 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("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;
return false;
}
static void on_library_loaded(const char *name, void *) {
LOGI("on_library_loaded called with name: %s", name);
static void on_library_loaded(const char* name, void*) {
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Bluetooth JNI loaded");
@@ -415,21 +311,14 @@ 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) {
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));
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("LibrePods initialized");
hook_func = (HookFunType)entries->hook_func;
return on_library_loaded;
}
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);
LOGI("sdp hook enabled: %d", enable);
}
@@ -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
}
}
@@ -20,92 +20,147 @@
package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.core.net.toUri
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.android.play.core.review.ReviewManagerFactory
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress
// import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
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) {
System.loadLibrary("l2c_fcr_hook")
}
System.loadLibrary("l2c_fcr_hook")
}
}
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
}
}
LibrePodsTheme(
m3eEnabled = m3eEnabled.value
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
LibrePodsTheme {
Main()
}
}
handleIncomingIntent(intent)
}
override fun onDestroy() {
@@ -140,106 +195,610 @@ class MainActivity : ComponentActivity() {
}
super.onStop()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
Log.d("LibrePods", "Parameter: $param = $value")
}
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit {putString("IRK", irkBase64)}
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
private fun hexStringToByteArray(hex: String): ByteArray {
val result = ByteArray(16)
for (i in 0 until 16) {
val hexByte = hex.substring(i * 2, i * 2 + 2)
result[i] = hexByte.toInt(16).toByte()
}
return result
}
}
@ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Main() {
val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE"
)
} else {
listOf(
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.ACCESS_FINE_LOCATION"
)
}
val otherPermissions = listOf(
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
val allPermissions = bluetoothPermissions + otherPermissions
val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel: AirPodsViewModel = viewModel()
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)
}
}
}
canDrawOverlays = Settings.canDrawOverlays(context)
}
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug")}"
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
val navController = rememberNavController()
fun bindService() {
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
val service = binder.getService()
airPodsService.value = service
airPodsViewModel.init(
service = service,
controlRepo = ControlCommandRepository(service.aacpManager),
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
appContext = context.applicationContext
)
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
Box (
modifier = Modifier
.fillMaxSize()
){
val backButtonBackdrop = rememberLayerBackdrop()
Box (
modifier = Modifier
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
.layerBackdrop(backButtonBackdrop)
) {
NavHost(
navController = navController,
startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen()
}
/*composable("onboarding") {
Onboarding(navController, context)
}*/
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
composable("camera_control") {
CameraControlScreen(navController)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
UpdateHearingTestScreen(navController)
}
composable("version_info") {
VersionScreen(navController)
}
composable("hearing_protection") {
HearingProtectionScreen(navController)
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
val showBackButton = remember{ mutableStateOf(false) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" // && destination.route != "onboarding"
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
}
}
AnimatedVisibility(
visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = 8.dp,
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
)
) {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop
)
}
}
context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
serviceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
}
}
}
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
if (airPodsService.value?.isConnectedLocally == true) {
isConnected.value = true
}
} else {
PermissionsScreen(
permissionState = permissionState,
canDrawOverlays = canDrawOverlays,
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
)
}
if (onboardingComplete) {
bindService()
}
NavigationRoot(
showReleaseNotes = !releaseNotesShown,
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
showOnboarding = !onboardingComplete,
onboardingComplete = {
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
bindService()
},
airPodsViewModel = airPodsViewModel
)
}
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(
permissionState: MultiplePermissionsState,
canDrawOverlays: Boolean,
onOverlaySettingsReturn: () -> Unit
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val scrollState = rememberScrollState()
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.05f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
)
Column(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "\uDBC2\uDEB7",
style = TextStyle(
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
)
)
Canvas(
modifier = Modifier
.size(120.dp)
.scale(pulseScale)
) {
val radius = size.minDimension / 2.2f
val centerX = size.width / 2
val centerY = size.height / 2
rotate(degrees = 45f) {
drawCircle(
color = accentColor.copy(alpha = 0.1f),
radius = radius * 1.3f,
center = Offset(centerX, centerY)
)
drawCircle(
color = accentColor.copy(alpha = 0.2f),
radius = radius * 1.1f,
center = Offset(centerX, centerY)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Permission Required",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.permissions_required),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
PermissionCard(
title = "Bluetooth Permissions",
description = "Required to communicate with your AirPods",
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
isGranted = permissionState.permissions.filter {
it.permission.contains("BLUETOOTH")
}.all { it.status.isGranted },
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Notification Permission",
description = "To show battery status",
icon = Icons.Default.Notifications,
isGranted = permissionState.permissions.find {
it.permission == "android.permission.POST_NOTIFICATIONS"
}?.status?.isGranted == true,
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Phone Permissions",
description = "For answering calls with Head Gestures",
icon = Icons.Default.Phone,
isGranted = permissionState.permissions.filter {
it.permission.contains("PHONE") || it.permission.contains("CALLS")
}.all { it.status.isGranted },
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Display Over Other Apps",
description = "For popup animations when AirPods connect",
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
isGranted = canDrawOverlays,
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { permissionState.launchMultiplePermissionRequest() },
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Ask for regular permissions",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${context.packageName}".toUri()
)
context.startActivity(intent)
onOverlaySettingsReturn()
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (canDrawOverlays) Color.Gray else accentColor
),
enabled = !canDrawOverlays,
shape = RoundedCornerShape(8.dp)
) {
Text(
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
if (!canDrawOverlays && basicPermissionsGranted) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
context.getSharedPreferences("settings", MODE_PRIVATE).edit {
putBoolean("overlay_permission_skipped", true)
}
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF757575)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Continue without overlay",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
}
}
@Composable
fun PermissionCard(
title: String,
description: String,
icon: ImageVector,
isGranted: Boolean,
backgroundColor: Color,
textColor: Color,
accentColor: Color
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
tint = if (isGranted) accentColor else Color.Gray,
modifier = Modifier.size(24.dp)
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(
text = title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = description,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f)
)
)
}
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
contentAlignment = Alignment.Center
) {
Text(
text = if (isGranted) "" else "!",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
)
}
}
}
}
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@@ -1,5 +0,0 @@
package me.kavishdevar.librepods.billing
object BillingManager {
lateinit var provider: BillingProvider
}
@@ -1,30 +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.billing
import android.app.Activity
import kotlinx.coroutines.flow.StateFlow
interface BillingProvider {
val isPremium: StateFlow<Boolean>
val price: StateFlow<String>
fun purchase(activity: Activity)
fun queryPurchases()
fun restorePurchases()
}
@@ -1,77 +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.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var purchaseJob: Job? = null
init {
queryPurchases()
}
override fun purchase(activity: Activity) {
activity.startActivity(
Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri())
)
purchaseJob?.cancel()
purchaseJob = scope.launch {
delay(5_000)
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
override fun queryPurchases() {
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
if (stored != _isPremium.value) {
_isPremium.value = stored
}
}
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
@@ -1,206 +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.billing
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
const val TAG = "PlayBillingProvider"
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
override val price: StateFlow<String> = _price
private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()
init {
connect()
}
private fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
scope.launch {
queryProductDetails()
queryExistingPurchases()
}
} else {
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
connect()
}
})
}
private suspend fun queryProductDetails() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(PREMIUM_PRODUCT_ID)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
).build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}")
val priceString = productDetails
?.oneTimePurchaseOfferDetails
?.formattedPrice
if (priceString != null) {
_price.value = priceString
}
} else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
}
}
private suspend fun queryExistingPurchases() {
val result = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
processPurchases(result.purchasesList)
}
override fun purchase(activity: Activity) {
val details = productDetails ?: run {
Log.e(TAG, "Product details not loaded yet")
return
}
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
)
).build()
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
}
}
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
}
}
private fun processPurchases(purchases: List<Purchase>) {
val hasPremium = purchases.any {
it.products.contains(PREMIUM_PRODUCT_ID) &&
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
// val purchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken)
// .build()
// scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->}
// }
// }
_isPremium.value = hasPremium
scope.launch {
purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { acknowledge(it) }
}
}
private suspend fun acknowledge(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
val result = billingClient.acknowledgePurchase(params)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
}
}
override fun queryPurchases() {
scope.launch {
queryExistingPurchases()
}
}
override fun restorePurchases() {
queryPurchases()
}
}
@@ -1,211 +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.bluetooth
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
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)
}
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)
}
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
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")
}
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.attSocket ?: return null
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
} 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.attSocket ?: 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.attSocket?: 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.attSocket ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
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)
}
} else {
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
}
} catch (e: Exception) {
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
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
}
}
}
@@ -1,76 +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.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
object BluetoothConnectionManager {
var aacpSocket: BluetoothSocket? = null
var attSocket: BluetoothSocket? = null
}
fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<psm>", "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("createSocket<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
@@ -0,0 +1,205 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.about),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.model.displayName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.actualModelNumber,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
airpodsInstance.serialNumber?: "",
"􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${airpodsInstance.rightSerialNumber}"
)
val serialNumber = remember { mutableStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.serial_number),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Text(
text = serialNumbers[serialNumber.value],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
}
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
currentState = airpodsInstance.version3,
independent = false,
height = rowHeight.value + 32.dp
)
}
}
@@ -0,0 +1,152 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return
}
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.audio),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
}
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
NavigationButton(
to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio),
navController = navController,
independent = false
)
}
}
}
@Preview
@Composable
fun AudioSettingsPreview() {
AudioSettings(rememberNavController())
}
@@ -0,0 +1,128 @@
/*
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.composables
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
charging: Boolean = false,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = if (batteryPercentage > 25)
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier
.background(backgroundColor), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = { batteryPercentage / 100f },
modifier = Modifier.size(40.dp),
color = batteryFillColor,
gapSize = 0.dp,
strokeCap = StrokeCap.Round,
strokeWidth = 4.dp,
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
)
Text(
text = "\uDBC0\uDEE6",
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
),
modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
}
}
@@ -0,0 +1,235 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
batteryStatus.value =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
intent.getParcelableArrayListExtra("data")
}?.toList() ?: listOf()
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
}
catch (_: IllegalArgumentException) {
Log.wtf("BatteryReceiver", "Receiver already unregistered")
}
}
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter()
.apply {
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
batteryReceiver,
batteryIntentFilter,
Context.RECEIVER_EXPORTED
)
}
}
previousBatteryStatus.value = batteryStatus.value
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
)
previousBatteryStatus.value = batteryStatus.value
}
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row {
Column (
modifier = Modifier
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3
)
{
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
leftCharging,
previousCharging = (prevLeftCharging && prevRightCharging)
)
singleDisplayed.value = true
}
else {
singleDisplayed.value = false
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
leftCharging,
"\uDBC6\uDCE5",
previousCharging = prevLeftCharging
)
}
if (leftLevel > 0 && rightLevel > 0)
{
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
{
BatteryIndicator(
rightLevel,
rightCharging,
"\uDBC6\uDCE8",
previousCharging = prevRightCharging
)
}
}
}
}
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
caseCharging,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
previousCharging = prevCaseCharging
)
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryView(AirPodsService(), preview = true)
}
}
@@ -0,0 +1,470 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
fun CallControlSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.call_controls),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
val service = ServiceManager.getService()!!
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
}?.value ?: byteArrayOf(0x00, 0x03)
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
var flipped by remember {
mutableStateOf(
callControlEnabledValue.contentEquals(
byteArrayOf(
0x00,
0x02
)
)
)
}
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
var showSinglePressDropdown by remember { mutableStateOf(false) }
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) }
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) }
var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
Log.d(
"CallControlSettings",
"Control command received, flipped: $newFlipped"
)
}
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
}
}
LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped")
}
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(58.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.answer_call),
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = stringResource(R.string.press_once),
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showSinglePressDropdown) {
showSinglePressDropdown = false
lastDismissTimeSingle = now
} else {
if (now - lastDismissTimeSingle > 250L) {
touchOffsetSingle = offset
showSinglePressDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffsetSingle = offset
if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) {
showSinglePressDropdown = true
}
lastDismissTimeSingle = now
parentDragActiveSingle = true
parentHoveredIndexSingle = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndexSingle = idx
},
onDragEnd = {
parentDragActiveSingle = false
parentHoveredIndexSingle?.let { idx ->
val options = listOf(pressOnceText, pressTwiceText)
if (idx in options.indices) {
val option = options[idx]
singlePressAction = option
doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}
}
parentHoveredIndexSingle = null
},
onDragCancel = {
parentDragActiveSingle = false
parentHoveredIndexSingle = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mute_unmute),
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPositionSingle = coordinates.positionInParent()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = singlePressAction,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "􀆏",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(start = 6.dp)
)
}
StyledDropdown(
expanded = showSinglePressDropdown,
onDismissRequest = {
showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis()
},
options = listOf(pressOnceText, pressTwiceText),
selectedOption = singlePressAction,
touchOffset = touchOffsetSingle,
boxPosition = boxPositionSingle,
externalHoveredIndex = parentHoveredIndexSingle,
externalDragActive = parentDragActiveSingle,
onOptionSelected = { option ->
singlePressAction = option
doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
},
hazeState = hazeState
)
}
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showDoublePressDropdown) {
showDoublePressDropdown = false
lastDismissTimeDouble = now
} else {
if (now - lastDismissTimeDouble > 250L) {
touchOffsetDouble = offset
showDoublePressDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffsetDouble = offset
if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) {
showDoublePressDropdown = true
}
lastDismissTimeDouble = now
parentDragActiveDouble = true
parentHoveredIndexDouble = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndexDouble = idx
},
onDragEnd = {
parentDragActiveDouble = false
parentHoveredIndexDouble?.let { idx ->
val options = listOf(pressOnceText, pressTwiceText)
if (idx in options.indices) {
val option = options[idx]
doublePressAction = option
singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}
}
parentHoveredIndexDouble = null
},
onDragCancel = {
parentDragActiveDouble = false
parentHoveredIndexDouble = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.hang_up),
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPositionDouble = coordinates.positionInParent()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = doublePressAction,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "􀆏",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(start = 6.dp)
)
}
StyledDropdown(
expanded = showDoublePressDropdown,
onDismissRequest = {
showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis()
},
options = listOf(pressOnceText, pressTwiceText),
selectedOption = doublePressAction,
touchOffset = touchOffsetDouble,
boxPosition = boxPositionDouble,
externalHoveredIndex = parentHoveredIndexDouble,
externalDragActive = parentDragActiveDouble,
onOptionSelected = { option ->
doublePressAction = option
singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf(
0x00,
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
},
hazeState = hazeState
)
}
}
}
}
}
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings(HazeState())
}
@@ -0,0 +1,217 @@
/*
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.composables
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.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.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.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
confirmText: String = "Enable",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) {
Box(
modifier = Modifier
// .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)
)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.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)
)
androidx.compose.foundation.layout.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)
)
androidx.compose.foundation.layout.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) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
onDismiss()
} else if (rightPressed) {
onConfirm()
}
}
leftPressed = false
rightPressed = false
}
}
}
}
},
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
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))
)
)
}
}
}
}
}
}
}
@@ -0,0 +1,82 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings() {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
StyledToggle(
label = stringResource(R.string.ear_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
sharedPreferenceKey = "automatic_ear_detection",
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
)
}
}
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}
@@ -18,7 +18,7 @@
@file:Suppress("unused")
package me.kavishdevar.librepods.presentation.components
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.constants.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
@@ -0,0 +1,109 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}
@@ -0,0 +1,297 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
fun MicrophoneSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
val service = ServiceManager.getService()!!
val micModeValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
}?.value?.get(0) ?: 0x00.toByte()
var selectedMode by remember {
mutableStateOf(
when (micModeValue) {
0x00.toByte() -> "Automatic"
0x01.toByte() -> "Always Right"
0x02.toByte() -> "Always Left"
else -> "Automatic"
}
)
}
var showDropdown by remember { mutableStateOf(false) }
var touchOffset by remember { mutableStateOf<Offset?>(null) }
var boxPosition by remember { mutableStateOf(Offset.Zero) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
) {
selectedMode = when (controlCommand.value[0]) {
0x00.toByte() -> "Automatic"
0x01.toByte() -> "Always Right"
0x02.toByte() -> "Always Left"
else -> "Automatic"
}
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
}
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (showDropdown) {
showDropdown = false
lastDismissTime = now
} else {
if (now - lastDismissTime > reopenThresholdMs) {
touchOffset = offset
showDropdown = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffset = offset
if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
showDropdown = true
}
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
},
onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
parentHoveredIndex = idx
},
onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
val options = listOf(
microphoneAutomaticText,
microphoneAlwaysRightText,
microphoneAlwaysLeftText
)
if (idx in options.indices) {
val option = options[idx]
selectedMode = option
showDropdown = false
lastDismissTime = System.currentTimeMillis()
val byteValue = when (option) {
options[0] -> 0x00
options[1] -> 0x01
options[2] -> 0x02
else -> 0x00
}
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}
}
parentHoveredIndex = null
},
onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
}
)
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.microphone_mode),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedMode,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "􀆏",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(start = 6.dp)
)
}
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
StyledDropdown(
expanded = showDropdown,
onDismissRequest = {
showDropdown = false
lastDismissTime = System.currentTimeMillis()
},
options = listOf(
microphoneAutomaticText,
microphoneAlwaysRightText,
microphoneAlwaysLeftText
),
selectedOption = selectedMode,
touchOffset = touchOffset,
boxPosition = boxPosition,
externalHoveredIndex = parentHoveredIndex,
externalDragActive = parentDragActive,
onOptionSelected = { option ->
selectedMode = option
showDropdown = false
val byteValue = when (option) {
microphoneAutomaticText -> 0x00
microphoneAlwaysRightText -> 0x01
microphoneAlwaysLeftText -> 0x02
else -> 0x00
}
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
},
hazeState = hazeState
)
}
}
}
}
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
MicrophoneSettings(HazeState())
}
@@ -0,0 +1,160 @@
/*
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.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
@Composable
fun NavigationButton(
to: String,
name: String,
navController: NavController, onClick: (() -> Unit)? = null,
independent: Boolean = true,
title: String? = null,
description: String? = null,
currentState: String? = null,
height: Dp = 58.dp,
) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Column {
if (title != null) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
){
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
)
)
}
}
Row(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
.height(height)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (onClick != null) onClick() else navController.navigate(to)
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White else Color.Black,
)
)
Spacer(modifier = Modifier.weight(1f))
if (currentState != null) {
Text(
text = currentState,
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.8f),
)
)
}
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
.padding(start = if (currentState != null) 6.dp else 0.dp)
)
}
if (description != null) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
// modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
}
}
@Preview
@Composable
fun NavigationButtonPreview() {
NavigationButton("to", "Name", NavController(LocalContext.current))
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() {
onClick = {},
textColor = Color.White,
)
}
}
@@ -0,0 +1,444 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(
service: AirPodsService,
) {
val context = LocalContext.current
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] == 1.toByte()
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY
} else {
mode
}
noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) {
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
d3a.floatValue = 0f
}
NoiseControlMode.OFF -> {
d1a.floatValue = 0f
d2a.floatValue = 1f
d3a.floatValue = 1f
}
NoiseControlMode.ADAPTIVE -> {
d1a.floatValue = 1f
d2a.floatValue = 0f
d3a.floatValue = 0f
}
NoiseControlMode.TRANSPARENCY -> {
d1a.floatValue = 0f
d2a.floatValue = 0f
d3a.floatValue = 1f
}
}
}
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
onModeSelected(noiseControlMode.value, true)
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
val noiseControlIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.ANC_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
)
)
}
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
val density = LocalDensity.current
val buttonCount = if (offListeningMode.value) 4 else 3
val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) }
var dragOffset by remember {
mutableFloatStateOf(
with(density) {
when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
}
}
)
}
val animationSpec: AnimationSpec<Float> = SpringSpec(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 0.01f
)
val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
}
val animatedOffset by animateFloatAsState(
targetValue = with(density) {
if (isDragging.value) dragOffset else targetOffset.toPx()
},
animationSpec = animationSpec,
label = "selector"
)
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
if (offListeningMode.value) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
Box(
modifier = Modifier
.width(buttonWidth)
.fillMaxHeight()
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.zIndex(0f)
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
dragOffset = (dragOffset + delta).coerceIn(
0f,
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
)
},
onDragStarted = { isDragging.value = true },
onDragStopped = {
isDragging.value = false
val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt()
val newMode = when(newIndex) {
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid
}
// Call onModeSelected which now handles service call but not callback
onModeSelected(newMode)
}
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(3.dp)
.background(selectedBackground, RoundedCornerShape(26.dp))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
) {
if (offListeningMode.value) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
if (offListeningMode.value) {
Text(
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
Text(
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.noise_cancellation),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}
@@ -0,0 +1,122 @@
/*
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.composables
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable
fun PressAndHoldSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888)
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.press_and_hold_airpods),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "long_press/Left",
name = stringResource(R.string.left),
navController = navController,
independent = false,
currentState = leftActionText,
)
HorizontalDivider(
thickness = 1.dp,
color = dividerColor,
modifier = Modifier
.padding(horizontal = 16.dp)
)
NavigationButton(
to = "long_press/Right",
name = stringResource(R.string.right),
navController = navController,
independent = false,
currentState = rightActionText,
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}
@@ -0,0 +1,284 @@
/*
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.composables
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable
fun StyledButton(
onClick: () -> Unit,
backdrop: Backdrop,
modifier: Modifier = Modifier,
isInteractive: Boolean = true,
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f,
content: @Composable RowScope.() -> Unit,
) {
val animationScope = rememberCoroutineScope()
val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var isPressed by remember { mutableStateOf(false) }
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
Row(
modifier
.then(
if (!isInteractive) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
blur(16f.dp.toPx())
},
layerBlock = null,
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = null,
highlight = { Highlight.Ambient.copy(alpha = 0f) }
)
} else {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
vibrancy()
blur(2f.dp.toPx())
refraction(12f.dp.toPx(), 24f.dp.toPx())
},
layerBlock = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
}
)
}
)
.clickable(
interactionSource = null,
indication = null,
role = Role.Button,
onClick = onClick
)
.then(
if (isInteractive) {
Modifier.pointerInput(animationScope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
} else {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = {
onClick()
}
)
}
}
)
.height(48f.dp)
.padding(horizontal = 16f.dp),
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
@@ -0,0 +1,244 @@
/*
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.composables
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun StyledDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
options: List<String>,
selectedOption: String,
touchOffset: Offset?,
boxPosition: Offset,
onOptionSelected: (String) -> Unit,
externalHoveredIndex: Int? = null,
externalDragActive: Boolean = false,
hazeState: HazeState,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) {
if (expanded) {
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
Popup(
offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()),
onDismissRequest = onDismissRequest
) {
AnimatedVisibility(
visible = true,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
) {
Card(
modifier = modifier
.padding(8.dp)
.width(300.dp)
.background(Color.Transparent)
.clip(RoundedCornerShape(8.dp)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(externalHoveredIndex, externalDragActive) {
if (externalDragActive) {
hoveredIndex = externalHoveredIndex
}
}
Column(
modifier = Modifier
.onGloballyPositioned { coordinates ->
popupSize = coordinates.size
}
.pointerInput(popupSize) {
detectDragGestures(
onDragStart = { offset ->
hoveredIndex = (offset.y / itemHeight.toPx()).toInt()
lastDragPosition = offset
},
onDrag = { change, _ ->
val y = change.position.y
hoveredIndex = (y / itemHeight.toPx()).toInt()
lastDragPosition = change.position
},
onDragEnd = {
val pos = lastDragPosition
val withinBounds = pos != null &&
pos.x >= 0f && pos.y >= 0f &&
pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat()
if (withinBounds) {
hoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
}
}
onDismissRequest()
} else {
hoveredIndex = null
}
}
)
}
) {
options.forEachIndexed { index, text ->
val isHovered =
if (externalDragActive && externalHoveredIndex != null) {
index == externalHoveredIndex
} else {
index == hoveredIndex
}
val isSystemInDarkTheme = isSystemInDarkTheme()
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.background(
Color.Transparent
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onOptionSelected(text)
onDismissRequest()
}
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.regular(),
block = fun HazeEffectScope.() {
alpha = 1f
backgroundColor = if (isSystemInDarkTheme) {
Color(0xB02C2C2E)
} else {
Color(0xB0FFFFFF)
}
tints = if (isHovered) listOf(
HazeTint(
color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
)
) else listOf()
})
.padding(horizontal = 12.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text,
style = TextStyle(
fontSize = 16.sp,
color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Checkbox(
checked = text == selectedOption,
onCheckedChange = { onOptionSelected(text) },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
)
)
}
}
if (index != options.lastIndex) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,260 @@
/*
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.composables
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
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.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
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.blur
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable
fun StyledIconButton(
onClick: () -> Unit,
icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
modifier: Modifier = Modifier,
) {
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = onClick,
shape = RoundedCornerShape(56.dp),
modifier = modifier
.padding(horizontal = 12.dp)
.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(56.dp) },
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
shadow = {
Shadow(
radius = 12f.dp,
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
)
},
layerBlock = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp)
val outline = shape.createOutline(size, layoutDirection, this)
val innerShadowOffset = 4f.dp.toPx()
val innerShadowBlurRadius = 4f.dp.toPx()
innerShadowLayer.alpha = progress
innerShadowLayer.renderEffect =
BlurEffect(
innerShadowBlurRadius,
innerShadowBlurRadius,
TileMode.Decal
)
innerShadowLayer.record {
drawOutline(outline, Color.Black.copy(0.2f))
translate(0f, innerShadowOffset) {
drawOutline(
outline,
Color.Transparent,
blendMode = BlendMode.Clear
)
}
}
drawLayer(innerShadowLayer)
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
)
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
},
effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
// blur(24f, TileMode.Decal)
},
)
.pointerInput(animationScope) {
val onDragStop: () -> Unit = {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
.size(48.dp),
) {
Text(
text = icon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
@@ -0,0 +1,168 @@
/*
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.composables
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true)
Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
.clip(RoundedCornerShape(52.dp))
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) {
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.zIndex(2f)
.height(64.dp + topPadding)
.fillMaxWidth()
.layerBackdrop(backdrop)
.hazeEffect(state = hazeState) {
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
}
) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding + 12.dp))
Text(
text = title,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
Row(
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
) {
actionButtons.forEach { actionButton ->
actionButton(backdrop)
}
}
content(topPadding + 64.dp, hazeState)
}
}
}
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit
) {
StyledScaffold(
title = title,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
) { _, _ ->
content()
}
}
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp) -> Unit
) {
StyledScaffold(
title = title,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
) { spacerValue, _ ->
content(spacerValue)
}
}
@@ -0,0 +1,184 @@
/*
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.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
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
data class SelectItem(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
data class SelectItem2(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: () -> Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
@Composable
fun StyledSelectList(
items: List<SelectItem>,
modifier: Modifier = Modifier
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
Column(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val visibleItems = items.filter { it.enabled }
visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0
val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null
val shape = when {
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp)
}
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(if (hasIcon) 72.dp else 55.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
itemBackgroundColor = backgroundColor
item.onClick()
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (hasIcon) {
Icon(
painter = painterResource(item.iconRes!!),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
.height(48.dp)
.wrapContentWidth()
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp)
.padding(start = if (hasIcon) 8.dp else 4.dp)
) {
Text(
item.name,
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
item.description?.let {
Text(
it,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
}
}
val floatAnimateState by animateFloatAsState(
targetValue = if (item.selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
}
if (!isLast) {
if (hasIcon) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 72.dp, end = 20.dp)
)
} else {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
)
}
}
}
}
}
@@ -0,0 +1,587 @@
/*
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.composables
import android.content.res.Configuration
import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
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.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.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
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.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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
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.tooling.preview.Preview
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun rememberMomentumAnimation(
maxScale: Float,
progressAnimationSpec: FiniteAnimationSpec<Float> =
spring(1f, 1000f, 0.01f),
velocityAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.5f, 250f, 5f),
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.4f, 400f, 0.01f),
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.6f, 400f, 0.01f)
): MomentumAnimation {
val animationScope = rememberCoroutineScope()
return remember(
maxScale,
animationScope,
progressAnimationSpec,
velocityAnimationSpec,
scaleXAnimationSpec,
scaleYAnimationSpec
) {
MomentumAnimation(
maxScale = maxScale,
animationScope = animationScope,
progressAnimationSpec = progressAnimationSpec,
velocityAnimationSpec = velocityAnimationSpec,
scaleXAnimationSpec = scaleXAnimationSpec,
scaleYAnimationSpec = scaleYAnimationSpec
)
}
}
class MomentumAnimation(
val maxScale: Float,
private val animationScope: CoroutineScope,
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
) {
private val velocityTracker = VelocityTracker()
private val progressAnimation = Animatable(0f)
private val velocityAnimation = Animatable(0f)
private val scaleXAnimation = Animatable(1f)
private val scaleYAnimation = Animatable(1f)
val progress: Float get() = progressAnimation.value
val velocity: Float get() = velocityAnimation.value
val scaleX: Float get() = scaleXAnimation.value
val scaleY: Float get() = scaleYAnimation.value
var isDragging: Boolean by mutableStateOf(false)
private set
val modifier: Modifier = Modifier.pointerInput(Unit) {
inspectDragGestures(
onDragStart = {
isDragging = true
velocityTracker.resetTracking()
startPressingAnimation()
},
onDragEnd = { change ->
isDragging = false
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
velocityTracker.addPointerInputChange(change)
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
},
onDragCancel = {
isDragging = false
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
}
) { change, _ ->
isDragging = true
velocityTracker.addPointerInputChange(change)
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
}
}
private fun updateVelocity(velocity: Velocity) {
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
}
private fun settleVelocity() {
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
}
fun startPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
}
}
fun endPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
}
}
}
@Composable
fun StyledSlider(
label: String? = null,
mutableFloatState: MutableFloatState,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List<Float> = emptyList(),
snapThreshold: Float = 0.05f,
startIcon: String? = null,
endIcon: String? = null,
startLabel: String? = null,
endLabel: String? = null,
independent: Boolean = false,
description: String? = null
) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme()
val accentColor =
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f)
else Color(0xFF787880).copy(0.36f)
val labelTextColor = if (isLightTheme) Color.Black else Color.White
val fraction by remember {
derivedStateOf {
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f)
}
}
val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) }
val trackPositionState = remember { mutableFloatStateOf(0f) }
val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
val content = @Composable {
Box(
Modifier
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
) {
Box(
Modifier
.padding(vertical = 4.dp)
.layerBackdrop(sliderBackdrop)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = startLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = endLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
if (startIcon != null) {
Text(
text = startIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
startIconWidthState.floatValue = it.size.width.toFloat()
}
)
}
Box(
Modifier
.weight(1f)
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
.onGloballyPositioned {
trackPositionState.floatValue =
it.positionInParent().y + it.size.height / 2f
}
) {
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(trackColor)
.height(6f.dp)
.fillMaxWidth()
)
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(accentColor)
.height(6f.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val fraction = fraction
val width =
(fraction * constraints.maxWidth).fastRoundToInt()
layout(width, placeable.height) {
placeable.place(0, 0)
}
}
)
}
if (endIcon != null) {
Text(
text = endIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
endIconWidthState.floatValue = it.size.width.toFloat()
}
)
}
}
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (snapPoints.isNotEmpty()) {
val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() }
val startOffset =
if (startIcon != null) startIconWidthState.floatValue + with(
density
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
Box(
Modifier
.fillMaxWidth()
) {
snapPoints.forEach { point ->
val pointFraction =
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f)
Box(
Modifier
.graphicsLayer {
translationX =
startOffset + pointFraction * trackWidth - 4.dp.toPx()
}
.size(2.dp)
.background(
trackColor,
CircleShape
)
)
}
}
}
}
}
}
}
Box(
Modifier
.graphicsLayer {
// val startOffset =
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
// translationX =
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
val startOffset =
if (startIcon != null)
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
else
with(density) { 8.dp.toPx() }
translationX =
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
.fastCoerceIn(
startOffset - size.width / 4f,
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
)
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
}
.draggable(
rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) {
val targetFraction = fraction + delta / trackWidth
val targetValue =
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold
) else targetValue
onValueChange(snappedValue)
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
// Remove this block as momentumAnimation handles pressing
},
onDragStopped = {
// Remove this block as momentumAnimation handles pressing
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
}
)
.then(momentumAnimation.modifier)
.drawBackdrop(
rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = momentumAnimation.progress
Highlight.Ambient.copy(alpha = progress)
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.05f)
)
},
innerShadow = {
val progress = momentumAnimation.progress
InnerShadow(
radius = 4f.dp * progress,
alpha = progress
)
},
layerBlock = {
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
},
onDrawSurface = {
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
},
effects = {
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
refractionWithDispersion(
height = 6f.dp.toPx() * progress,
amount = size.height / 2f * progress
)
}
)
.size(40f.dp, 24f.dp)
)
}
}
if (independent) {
Column (
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
if (label != null) {
Text(
text = label,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = labelTextColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.heightIn(min = 58.dp),
contentAlignment = Alignment.Center
) {
content()
}
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
}
} else {
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
content()
}
}
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
val nearest = points.minByOrNull { abs(it - value) } ?: value
return if (abs(nearest - value) <= threshold) nearest else value
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun StyledSliderPreview() {
val a = remember { mutableFloatStateOf(0.5f) }
Box(
Modifier
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
.padding(16.dp)
.fillMaxSize()
) {
Box (
Modifier.align(Alignment.Center)
)
{
StyledSlider(
mutableFloatState = a,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
)
}
}
}
@@ -16,10 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
package me.kavishdevar.librepods.composables
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.Animatable
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring
@@ -58,10 +58,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
@@ -70,12 +68,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable
fun StyledSwitch(
@@ -84,12 +81,9 @@ fun StyledSwitch(
enabled: Boolean = true,
) {
val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
0x805B5B5E
) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val trackWidth = 64.dp
val trackHeight = 28.dp
@@ -104,25 +98,24 @@ fun StyledSwitch(
val animatedFraction = remember { Animatable(fraction) }
val trackWidthPx = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val scope = rememberCoroutineScope()
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val targetColor = if (checked) onColor else offColor
val animatedTrackColor by animateColorAsState(targetColor)
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) {
if (!isFirstComposition.value) {
if (checked) {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
} else {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
}
coroutineScope {
launch {
val targetColor = if (checked) onColor else offColor
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
}
launch {
val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
@@ -147,7 +140,7 @@ fun StyledSwitch(
modifier = Modifier
.layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2))
.background(animatedTrackColor)
.background(animatedTrackColor.value)
.width(trackWidth)
.height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -161,31 +154,27 @@ fun StyledSwitch(
.then(if (enabled) Modifier.draggable(
rememberDraggableState { delta ->
if (trackWidthPx.floatValue > 0f) {
val oldFraction = animatedFraction.value
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
scope.launch {
animationScope.launch {
animatedFraction.snapTo(newFraction)
}
totalDrag.floatValue += abs(delta)
totalDrag.floatValue += kotlin.math.abs(delta)
val newChecked = newFraction >= 0.5f
if (newChecked != checked) {
onCheckedChange(newChecked)
}
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
totalDrag.floatValue = 0f
scope.launch {
animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
scope.launch {
animationScope.launch {
if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked
onCheckedChange(newChecked)
@@ -273,12 +262,7 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress))
},
effects = {
lens(
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
}
)
.width(thumbWidth)
@@ -0,0 +1,687 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
independent: Boolean = true,
enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by checkedState
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
attHandle: ATTHandles,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = try {
attManager.read(attHandle).getOrNull(0)?.toInt()
} catch (e: Exception) {
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
null
} ?: 0
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
attManager.enableNotifications(attHandle)
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
LaunchedEffect(checked) {
if (attManager.socket?.isConnected != true) return@LaunchedEffect
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
}
val listener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
if (value.isNotEmpty()) {
checked = value[0].toInt() != 0
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
} else {
Log.w("StyledToggle", "Empty value in notification for $label")
}
}
}
}
LaunchedEffect(Unit) {
attManager.registerListener(attHandle, listener)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(attHandle, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val context = LocalContext.current
val sharedPrefs = context.getSharedPreferences("preview", 0)
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
sharedPreferences = sharedPrefs
)
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
@@ -16,16 +16,14 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
package me.kavishdevar.librepods.constants
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),
@@ -42,7 +40,6 @@ object BatteryStatus {
const val CHARGING = 1
const val NOT_CHARGING = 2
const val DISCONNECTED = 4
const val OPTIMIZED_CHARGING = 5
}
@Parcelize
@@ -61,7 +58,6 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
else -> null
}
}
@@ -74,7 +70,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 +78,10 @@ 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 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 +238,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
@@ -16,9 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
package me.kavishdevar.librepods.constants
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction {
PLAY_PAUSE,
@@ -1,70 +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.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
return aacpManager.controlCommandStatusList.associate {
it.identifier to it.value
}
}
}
@@ -1,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" }
}
}
@@ -1,8 +0,0 @@
package me.kavishdevar.librepods.data
interface XposedRemotePref {
fun isAvailable(): Boolean
fun getBoolean(key: String, def: Boolean): Boolean
fun putBoolean(key: String, value: Boolean)
}
@@ -1,21 +0,0 @@
package me.kavishdevar.librepods.data
import androidx.core.content.edit
import me.kavishdevar.librepods.utils.XposedServiceHolder
class XposedRemotePrefImpl: XposedRemotePref {
override fun isAvailable(): Boolean {
return XposedServiceHolder.service != null
}
override fun getBoolean(key: String, def: Boolean): Boolean {
val s = XposedServiceHolder.service ?: return def
return s.getRemotePreferences("me.kavishdevar.librepods").getBoolean(key, def)
}
override fun putBoolean(key: String, value: Boolean) {
val s = XposedServiceHolder.service ?: return
s.getRemotePreferences("me.kavishdevar.librepods")
.edit { putBoolean(key, value) }
}
}
@@ -1,5 +0,0 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}
@@ -1,9 +0,0 @@
package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
data class UpdateItem(
val titleRes: Int,
val descriptionRes: Int,
val demoComposeable: @Composable () -> Unit
)
@@ -1,35 +0,0 @@
package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
val update0_3_1 = listOf(
UpdateItem(
titleRes = R.string.material3e,
descriptionRes = R.string.update_m3e_description,
demoComposeable = @Composable {
AirPodsSettingsScreenPreviewMaterial()
}
),
UpdateItem(
titleRes = R.string.equalizer,
descriptionRes = R.string.update_equalizer_description,
demoComposeable = @Composable {
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
EqualizerScreenPreviewApple()
}
DesignSystem.Material -> {
EqualizerScreenPreviewMaterial()
}
}
}
),
)
val updates = update0_3_1
@@ -1,528 +0,0 @@
package me.kavishdevar.librepods.presentation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object MaterialIcons {
val notifications: ImageVector
get() {
if (_notifications != null) {
return _notifications!!
}
_notifications =
ImageVector.Builder(
name = "notifications",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(4f, 19f)
verticalLineTo(17f)
horizontalLineTo(6f)
verticalLineTo(10f)
quadTo(6f, 7.93f, 7.25f, 6.31f)
reflectiveQuadTo(10.5f, 4.2f)
verticalLineTo(3.5f)
quadToRelative(0f, -0.63f, 0.44f, -1.06f)
reflectiveQuadTo(12f, 2f)
reflectiveQuadToRelative(1.06f, 0.44f)
reflectiveQuadTo(13.5f, 3.5f)
verticalLineTo(4.2f)
quadToRelative(2f, 0.5f, 3.25f, 2.11f)
reflectiveQuadTo(18f, 10f)
verticalLineToRelative(7f)
horizontalLineToRelative(2f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
close()
moveToRelative(8f, -7.5f)
close()
moveTo(12f, 22f)
quadToRelative(-0.82f, 0f, -1.41f, -0.59f)
reflectiveQuadTo(10f, 20f)
horizontalLineToRelative(4f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(12f, 22f)
close()
moveTo(8f, 17f)
horizontalLineToRelative(8f)
verticalLineTo(10f)
quadTo(16f, 8.35f, 14.83f, 7.18f)
reflectiveQuadTo(12f, 6f)
reflectiveQuadTo(9.18f, 7.18f)
reflectiveQuadTo(8f, 10f)
verticalLineToRelative(7f)
close()
}
}
.build()
return _notifications!!
}
private var _notifications: ImageVector? = null
val headset_off: ImageVector
get() {
if (_headset_off != null) {
return _headset_off!!
}
_headset_off =
ImageVector.Builder(
name = "headset_off",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(21f, 18.15f)
lineToRelative(-2f, -2f)
verticalLineTo(14f)
horizontalLineTo(16.85f)
lineToRelative(-2f, -2f)
horizontalLineTo(19f)
verticalLineTo(11f)
quadTo(19f, 8.05f, 16.95f, 6.02f)
reflectiveQuadTo(12f, 4f)
quadTo(10.9f, 4f, 9.91f, 4.31f)
reflectiveQuadTo(8.1f, 5.2f)
lineTo(6.65f, 3.8f)
quadTo(7.78f, 2.92f, 9.14f, 2.46f)
reflectiveQuadTo(12f, 2f)
quadToRelative(1.85f, 0f, 3.49f, 0.7f)
reflectiveQuadToRelative(2.86f, 1.93f)
reflectiveQuadToRelative(1.94f, 2.86f)
reflectiveQuadTo(21f, 11f)
verticalLineToRelative(7.15f)
close()
moveTo(12f, 23f)
verticalLineTo(21f)
horizontalLineToRelative(6.18f)
lineToRelative(-1f, -1f)
horizontalLineTo(15f)
verticalLineTo(17.83f)
lineTo(5.53f, 8.35f)
quadTo(5.3f, 8.95f, 5.15f, 9.64f)
reflectiveQuadTo(5f, 11f)
verticalLineToRelative(1f)
horizontalLineTo(9f)
verticalLineToRelative(8f)
horizontalLineTo(5f)
quadTo(4.18f, 20f, 3.59f, 19.41f)
reflectiveQuadTo(3f, 18f)
verticalLineTo(11f)
quadTo(3f, 9.88f, 3.26f, 8.82f)
reflectiveQuadToRelative(0.76f, -2f)
lineTo(0.68f, 3.5f)
lineTo(2.1f, 2.1f)
lineTo(21.88f, 21.9f)
verticalLineTo(23f)
horizontalLineTo(12f)
close()
moveTo(5f, 18f)
horizontalLineTo(7f)
verticalLineTo(14f)
horizontalLineTo(5f)
verticalLineToRelative(4f)
close()
moveTo(5f, 14f)
horizontalLineTo(7f)
horizontalLineTo(5f)
close()
moveToRelative(11.85f, 0f)
horizontalLineTo(19f)
horizontalLineTo(16.85f)
close()
}
}
.build()
return _headset_off!!
}
private var _headset_off: ImageVector? = null
val pause: ImageVector
get() {
if (_pause != null) {
return _pause!!
}
_pause =
ImageVector.Builder(
name = "pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(13f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(13f)
close()
moveTo(5f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(5f)
close()
moveTo(15f, 17f)
horizontalLineToRelative(2f)
verticalLineTo(7f)
horizontalLineTo(15f)
verticalLineTo(17f)
close()
moveTo(7f, 17f)
horizontalLineTo(9f)
verticalLineTo(7f)
horizontalLineTo(7f)
verticalLineTo(17f)
close()
moveTo(7f, 7f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
moveToRelative(8f, 0f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
}
}
.build()
return _pause!!
}
private var _pause: ImageVector? = null
val bluetooth: ImageVector
get() {
if (_bluetooth != null) {
return _bluetooth!!
}
_bluetooth =
ImageVector.Builder(
name = "bluetooth",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(11f, 22f)
verticalLineTo(14.4f)
lineTo(6.4f, 19f)
lineTo(5f, 17.6f)
lineTo(10.6f, 12f)
lineTo(5f, 6.4f)
lineTo(6.4f, 5f)
lineTo(11f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(13.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(12f, 22f)
horizontalLineTo(11f)
close()
moveTo(13f, 9.6f)
lineTo(14.9f, 7.7f)
lineTo(13f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(14.9f, 16.3f)
lineTo(13f, 14.4f)
verticalLineToRelative(3.75f)
close()
}
}
.build()
return _bluetooth!!
}
private var _bluetooth: ImageVector? = null
val bluetooth_searching: ImageVector
get() {
if (_bluetooth_searching != null) {
return _bluetooth_searching!!
}
_bluetooth_searching =
ImageVector.Builder(
name = "bluetooth_searching",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(9f, 22f)
verticalLineTo(14.4f)
lineTo(4.4f, 19f)
lineTo(3f, 17.6f)
lineTo(8.6f, 12f)
lineTo(3f, 6.4f)
lineTo(4.4f, 5f)
lineTo(9f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(11.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(10f, 22f)
horizontalLineTo(9f)
close()
moveTo(11f, 9.6f)
lineTo(12.9f, 7.7f)
lineTo(11f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(12.9f, 16.3f)
lineTo(11f, 14.4f)
verticalLineToRelative(3.75f)
close()
moveToRelative(5.55f, -3.8f)
lineTo(14.25f, 12f)
lineToRelative(2.3f, -2.3f)
quadToRelative(0.23f, 0.55f, 0.36f, 1.13f)
reflectiveQuadTo(17.05f, 12f)
reflectiveQuadToRelative(-0.14f, 1.19f)
quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f)
close()
moveTo(19.5f, 17.2f)
lineTo(18.25f, 16f)
quadToRelative(0.5f, -0.93f, 0.78f, -1.94f)
reflectiveQuadTo(19.3f, 12f)
reflectiveQuadTo(19.03f, 9.94f)
quadTo(18.75f, 8.92f, 18.25f, 8f)
lineTo(19.5f, 6.75f)
quadToRelative(0.73f, 1.2f, 1.11f, 2.52f)
reflectiveQuadTo(21f, 12f)
reflectiveQuadToRelative(-0.39f, 2.71f)
quadTo(20.23f, 16.02f, 19.5f, 17.2f)
close()
}
}
.build()
return _bluetooth_searching!!
}
private var _bluetooth_searching: ImageVector? = null
val call: ImageVector
get() {
if (_call != null) {
return _call!!
}
_call =
ImageVector.Builder(
name = "call",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(19.95f, 21f)
quadToRelative(-3.13f, 0f, -6.18f, -1.36f)
reflectiveQuadTo(8.23f, 15.78f)
quadTo(5.73f, 13.27f, 4.36f, 10.23f)
reflectiveQuadTo(3f, 4.05f)
quadTo(3f, 3.6f, 3.3f, 3.3f)
reflectiveQuadTo(4.05f, 3f)
horizontalLineTo(8.1f)
quadTo(8.45f, 3f, 8.73f, 3.24f)
reflectiveQuadTo(9.05f, 3.8f)
lineTo(9.7f, 7.3f)
quadTo(9.75f, 7.7f, 9.68f, 7.97f)
reflectiveQuadTo(9.4f, 8.45f)
lineTo(6.98f, 10.9f)
quadToRelative(0.5f, 0.93f, 1.19f, 1.79f)
reflectiveQuadToRelative(1.51f, 1.66f)
quadToRelative(0.78f, 0.78f, 1.63f, 1.44f)
reflectiveQuadTo(13.1f, 17f)
lineToRelative(2.35f, -2.35f)
quadToRelative(0.22f, -0.23f, 0.59f, -0.34f)
reflectiveQuadToRelative(0.71f, -0.06f)
lineToRelative(3.45f, 0.7f)
quadToRelative(0.35f, 0.1f, 0.57f, 0.36f)
reflectiveQuadTo(21f, 15.9f)
verticalLineToRelative(4.05f)
quadToRelative(0f, 0.45f, -0.3f, 0.75f)
reflectiveQuadTo(19.95f, 21f)
close()
moveTo(6.03f, 9f)
lineTo(7.68f, 7.35f)
lineTo(7.25f, 5f)
horizontalLineTo(5.03f)
quadTo(5.15f, 6.02f, 5.38f, 7.02f)
reflectiveQuadTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
quadToRelative(0.97f, 0.43f, 1.99f, 0.68f)
reflectiveQuadTo(19f, 18.95f)
verticalLineToRelative(-2.2f)
lineTo(16.65f, 16.27f)
lineToRelative(-1.68f, 1.68f)
close()
moveTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
close()
}
}
.build()
return _call!!
}
private var _call: ImageVector? = null
val stack: ImageVector
get() {
if (_stack != null) {
return _stack!!
}
_stack =
ImageVector.Builder(
name = "stack",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(6f, 14f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
quadTo(3.18f, 16f, 2.59f, 15.41f)
reflectiveQuadTo(2f, 14f)
verticalLineTo(4f)
quadTo(2f, 3.17f, 2.59f, 2.59f)
reflectiveQuadTo(4f, 2f)
horizontalLineTo(14f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(16f, 4f)
verticalLineTo(6f)
horizontalLineTo(14f)
verticalLineTo(4f)
horizontalLineTo(4f)
verticalLineTo(14f)
horizontalLineTo(6f)
close()
moveToRelative(4f, 8f)
quadTo(9.18f, 22f, 8.59f, 21.41f)
reflectiveQuadTo(8f, 20f)
verticalLineTo(10f)
quadTo(8f, 9.17f, 8.59f, 8.59f)
reflectiveQuadTo(10f, 8f)
horizontalLineTo(20f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(22f, 10f)
verticalLineTo(20f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(20f, 22f)
horizontalLineTo(10f)
close()
moveToRelative(0f, -2f)
horizontalLineTo(20f)
verticalLineTo(10f)
horizontalLineTo(10f)
verticalLineTo(20f)
close()
moveToRelative(5f, -5f)
close()
}
}
.build()
return _stack!!
}
private var _stack: ImageVector? = null
}
@@ -1,79 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?,
navigateToVersion: () -> Unit
) {
val serialNumbers = when (LocalDesignSystem.current) {
DesignSystem.Apple -> listOf(
serialNumbers[0],
"􀀛 ${serialNumbers[1]}",
"􀀧 ${serialNumbers[2]}"
)
DesignSystem.Material -> listOf(
serialNumbers[0],
stringResource(R.string.left) + " " + {serialNumbers[1]},
stringResource(R.string.right) + " " + {serialNumbers[2]},
)
}
val serialNumber = remember { mutableIntStateOf(0) }
StyledList (title = stringResource(R.string.about)) {
StyledListItem(
name = stringResource(R.string.model_name),
description = modelName
)
StyledListItem(
name = stringResource(R.string.model_number),
description = actualModel
)
StyledListItem (
name = stringResource(R.string.serial_number),
description = serialNumbers[serialNumber.intValue],
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
)
StyledListItem(
name = stringResource(R.string.version),
description = version,
onClick = navigateToVersion,
)
}
}
@@ -1,52 +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.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@Composable
fun AppInfoCard(
navigateToReleaseNotesScreen: (() -> Unit)? = null,
) {
StyledList(title = stringResource(R.string.about)) {
StyledListItem(
name = stringResource(R.string.version),
description = BuildConfig.VERSION_NAME,
onClick = navigateToReleaseNotesScreen
)
StyledListItem(
name = stringResource(R.string.version_code),
description = BuildConfig.VERSION_CODE.toString(),
)
StyledListItem(
name = stringResource(R.string.flavor),
description = BuildConfig.FLAVOR,
)
StyledListItem(
name = stringResource(R.string.build_type),
description = BuildConfig.BUILD_TYPE,
)
}
}
@@ -1,98 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(
adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
customEqCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
conversationalAwarenessChecked: Boolean,
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
navigateToAdaptiveStrength: () -> Unit,
navigateToEqualizer: () -> Unit,
vendorIdHook: Boolean,
isPremium: Boolean
) {
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
StyledList(title = stringResource(R.string.audio)) {
if (adaptiveVolumeCapability) {
StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
checked = adaptiveVolumeChecked,
onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium,
)
}
if (conversationalAwarenessCapability) {
StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
checked = conversationalAwarenessChecked,
onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium,
)
}
if (loudSoundReductionCapability && vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium,
)
}
if (adaptiveAudioCapability) {
StyledListItem(
name = stringResource(R.string.adaptive_audio),
onClick = navigateToAdaptiveStrength,
)
}
if (customEqCapability) {
StyledListItem(
name = stringResource(R.string.equalizer),
onClick = navigateToEqualizer,
)
}
}
}
}
@@ -1,212 +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 android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
status: Int,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
) {
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
val optimizedLimit = 0.8f
val progress = batteryPercentage / 100f
Canvas(modifier = Modifier.size(34.dp)) {
val startAngle = -90f
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
val inset = strokeWidthPx / 2
Rect(
left = inset,
top = inset,
right = size.width - inset,
bottom = size.height - inset
)
val radius = size.minDimension / 2
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
drawArc(
color = trackColor,
startAngle = startAngle,
sweepAngle = 360f * optimizedLimit,
useCenter = false,
style = stroke
)
val sweep = 360f * min(progress, optimizedLimit)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
style = stroke
)
// ---- PILL MARKER AT 80% ----
val angleDeg = startAngle + 360f * optimizedLimit
val angleRad = Math.toRadians(angleDeg.toDouble())
val arcRadius = radius - strokeWidthPx
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
val dirX = center.x - outerX
val dirY = center.y - outerY
val length = sqrt(dirX * dirX + dirY * dirY)
val normX = dirX / length
val normY = dirY / length
val startX = outerX - normX * strokeWidthPx / 2
val startY = outerY - normY * strokeWidthPx / 2
val endX = center.x - normX * gapFromCenterPx
val endY = center.y - normY * gapFromCenterPx
drawLine(
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
start = Offset(startX, startY),
end = Offset(endX, endY),
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
} else {
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = stroke
)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = 360f * progress,
useCenter = false,
style = stroke
)
}
}
Text(
text = "\uDBC0\uDEE6", style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
), modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
LibrePodsTheme(m3eEnabled = false) {
BatteryIndicator(
batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING,
prefix = "\uDBC6\uDCE5",
previousCharging = false
)
}
}
@@ -1,174 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(
batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val singleDisplayed = remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.widthIn(max = 500.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
singleDisplayed.value = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(bg)
.padding(16.dp)
) {
BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
}
}
@@ -1,89 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
fun CallControlSettings(
flipped: Boolean,
navigateToCallControlScreen: (action: String) -> Unit,
) {
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
val muteUnmuteText = stringResource(R.string.mute_unmute)
val hangUpText = stringResource(R.string.hang_up)
StyledList(title = stringResource(R.string.call_controls)) {
StyledListItem(
name = stringResource(R.string.answer_call),
description = stringResource(R.string.press_once),
enabled = false
)
StyledListItem(
name = muteUnmuteText,
description = singlePressAction,
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
)
StyledListItem(
name = hangUpText,
description = doublePressAction,
onClick = { navigateToCallControlScreen(hangUpText) }
)
// StyledListItem(
// name = pressOnceText,
// selected = doublePressAction == pressOnceText,
// onClick = {
// doublePressAction = pressOnceText
// singlePressAction = pressTwiceText
//
// onCallControlValueChanged(true)
// }
// )
//
// StyledListItem(
// name = pressTwiceText,
// selected = doublePressAction == pressTwiceText,
// onClick = {
// doublePressAction = pressTwiceText
// singlePressAction = pressOnceText
//
// onCallControlValueChanged(false)
// }
// )
}
}
@@ -1,218 +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.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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
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.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
confirmText: String = "Enable",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
backdrop: LayerBackdrop,
) {
AnimatedVisibility(
visible = showDialog.value,
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut()
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
BasicAlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMediumEmphasized
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(
onClick = onDismiss
) {
Text(
text = dismissText,
style = MaterialTheme.typography.labelMedium
)
}
TextButton(
onClick = onConfirm
) {
Text(
text = confirmText,
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
DesignSystem.Apple -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
val isDarkTheme = isSystemInDarkTheme()
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)
},
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 = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = MaterialTheme.typography.bodyMedium,
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)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
materialButtonStyle = MaterialButtonStyle.Outlined,
) {
Text(
text = dismissText,
style = MaterialTheme.typography.bodyMedium
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
materialButtonStyle = MaterialButtonStyle.Filled,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
text = confirmText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}
}
}
}
@@ -1,49 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
StyledList {
StyledToggle(
label = stringResource(R.string.ear_detection),
checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged
)
}
}
@@ -1,56 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
StyledList(title = stringResource(R.string.device_info)) {
StyledListItem(
name = stringResource(R.string.manufacturer),
description = Build.MANUFACTURER,
enabled = false
)
StyledListItem(
name = stringResource(R.string.model_number),
description = Build.MODEL,
enabled = false
)
StyledListItem(
name = stringResource(R.string.build_id),
description = Build.DISPLAY,
enabled = false
)
StyledListItem(
name = stringResource(R.string.version),
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
enabled = false
)
StyledListItem(
name = stringResource(R.string.xposed_available),
description = if (XposedState.isAvailable) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
StyledListItem(
name = stringResource(R.string.app_enabled_in_xposed),
description = if (XposedState.bluetoothScopeEnabled) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
)
}
}
@@ -1,62 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(
hasPPECapability: Boolean,
hasHearingAidCapability: Boolean,
vendorIdHook: Boolean,
navigateToHearingProtection: () -> Unit,
navigateToHearingAid: () -> Unit
) {
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) {
StyledList(title = stringResource(R.string.hearing_health)) {
StyledListItem(
name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
)
StyledListItem(
name = stringResource(R.string.hearing_aid),
onClick = navigateToHearingAid
)
}
} else if (shouldShowHearingAid) {
StyledListItem(
name = stringResource(R.string.hearing_aid),
onClick = navigateToHearingAid
)
} else if (hasPPECapability) {
StyledListItem(
title = stringResource(R.string.hearing_health),
name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
)
}
}
@@ -1,522 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
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.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(
showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
val options = buildList {
if (showOffListeningMode) {
add(
Triple(
NoiseControlMode.OFF,
R.string.off,
R.drawable.noise_cancellation
)
)
}
add(
Triple(
NoiseControlMode.TRANSPARENCY,
R.string.transparency,
R.drawable.transparency
)
)
add(
Triple(
NoiseControlMode.ADAPTIVE,
R.string.adaptive,
R.drawable.adaptive
)
)
add(
Triple(
NoiseControlMode.NOISE_CANCELLATION,
R.string.noise_cancellation,
R.drawable.noise_cancellation
)
)
}
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
Column {
Box(
modifier = Modifier
.background(Color.Transparent)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
) {
Text(
text = stringResource(R.string.noise_control),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
options.forEachIndexed { index, (mode, labelRes, iconRes) ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
ToggleButton(
checked = selectedMode == mode,
onCheckedChange = {
if (it) {
onNoiseControlModeChanged(mode.ordinal + 1)
}
},
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors = ToggleButtonDefaults.toggleButtonColors()
.copy(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Icon(
bitmap = ImageBitmap.imageResource(iconRes),
contentDescription = null,
modifier = Modifier.size(42.dp)
)
}
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
DesignSystem.Apple -> {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) }
// this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY
} else {
mode
}
noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
d3a.floatValue = 0f
}
NoiseControlMode.OFF -> {
d1a.floatValue = 0f
d2a.floatValue = 1f
d3a.floatValue = 1f
}
NoiseControlMode.ADAPTIVE -> {
d1a.floatValue = 1f
d2a.floatValue = 0f
d3a.floatValue = 0f
}
NoiseControlMode.TRANSPARENCY -> {
d1a.floatValue = 0f
d2a.floatValue = 0f
d3a.floatValue = 1f
}
}
}
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
noiseControlMode.value = NoiseControlMode.entries[index]
onModeSelected(noiseControlMode.value, received = true)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 4.dp)
) {
Text(
text = stringResource(R.string.noise_control),
color = MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
val density = LocalDensity.current
val buttonCount = if (showOffListeningMode) 4 else 3
val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) }
var dragOffset by remember {
mutableFloatStateOf(
with(density) {
when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
}
}
)
}
val animationSpec: AnimationSpec<Float> = SpringSpec(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 0.01f
)
val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
}
val animatedOffset by animateFloatAsState(
targetValue = with(density) {
if (isDragging.value) dragOffset else targetOffset.toPx()
},
animationSpec = animationSpec,
label = "selector"
)
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
Box(
modifier = Modifier
.width(buttonWidth)
.fillMaxHeight()
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
.zIndex(0f)
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
dragOffset = (dragOffset + delta).coerceIn(
0f,
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
)
},
onDragStarted = { isDragging.value = true },
onDragStopped = {
isDragging.value = false
val position =
dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt()
val newMode = when (newIndex) {
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid
}
onModeSelected(newMode)
}
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(3.dp)
.background(selectedBackground, RoundedCornerShape(26.dp))
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
) {
if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d1a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
}
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.adaptive),
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
VerticalDivider(
thickness = 1.dp,
modifier = Modifier
.padding(vertical = 10.dp)
.alpha(d3a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
)
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
modifier = Modifier.weight(1f),
usePadding = false
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp)
) {
if (showOffListeningMode) {
Text(
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
Text(
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(R.string.noise_cancellation),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
@Preview
@Composable
fun NoiseControlSettingsPreview() {
LibrePodsTheme(
m3eEnabled = true
) {
Box(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
NoiseControlSettings(
showOffListeningMode = false,
noiseControlModeValue = 2,
onNoiseControlModeChanged = { }
)
}
}
}
@@ -1,59 +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.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.StemAction
@Composable
fun PressAndHoldSettings(
leftAction: StemAction,
rightAction: StemAction,
navigateToLeftLongPress: () -> Unit,
navigateToRightLongPress: () -> Unit
) {
val leftActionText = when (leftAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
val rightActionText = when (rightAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
StyledList(
title = stringResource(R.string.press_and_hold_airpods)
) {
StyledListItem(
name = stringResource(R.string.left),
description = leftActionText,
onClick = navigateToLeftLongPress
)
StyledListItem(
name = stringResource(R.string.right),
description = rightActionText,
onClick = navigateToRightLongPress,
)
}
}
@@ -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)
}
}
}
@@ -1,404 +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 android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
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
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
enum class MaterialButtonStyle {
Tonal,
Normal,
Outlined,
Filled
}
@Composable
fun StyledButton(
onClick: () -> Unit,
backdrop: Backdrop,
modifier: Modifier = Modifier,
isInteractive: Boolean = true,
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f,
enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Tonal, // picking tonal because most usages assume a transparent/gray background, tonal will give a slightly less vibrant background
content: @Composable RowScope.() -> Unit,
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
when (materialButtonStyle) {
MaterialButtonStyle.Filled -> {
Button(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
MaterialButtonStyle.Tonal -> {
FilledTonalButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
)
}
MaterialButtonStyle.Outlined -> {
OutlinedButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
MaterialButtonStyle.Normal -> {
TextButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
}
}
DesignSystem.Apple -> {
val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var isPressed by remember { mutableStateOf(false) }
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
Row(
modifier
.then(
if (!isInteractive) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
blur(16f.dp.toPx())
},
layerBlock = null,
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified && enabled) {
val color = if (isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
} else {
if (isPressed && enabled) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
}
},
onDrawFront = null,
highlight = { Highlight.Ambient.copy(alpha = 0f) }
)
} else {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
vibrancy()
blur(2f.dp.toPx())
lens(
refractionHeight = 12f.dp.toPx(),
refractionAmount = 24f.dp.toPx(),
depthEffect = true,
chromaticAberration = true
)
},
layerBlock = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX =
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset =
pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
}
)
}
)
.clickable(
interactionSource = null,
indication = null,
role = Role.Button,
onClick = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
}
)
.then(
if (isInteractive) {
Modifier.pointerInput(scope) {
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
)
}
}
}
}
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) }
}
}
},
onDragEnd = {
onDragStop()
},
onDragCancel = onDragStop
) { _, dragAmount ->
if (enabled) {
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
}
} else {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
}
)
}
}
)
.height(48f.dp)
.padding(horizontal = 16f.dp),
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
@@ -1,437 +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 android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
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
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
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 com.kyant.backdrop.shadow.InnerShadow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable
fun StyledIconButton(
modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
when (materialButtonStyle) {
MaterialButtonStyle.Tonal -> {
FilledTonalIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Filled -> {
FilledIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Outlined -> {
OutlinedIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Normal -> {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
DesignSystem.Apple -> {
val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val density = LocalDensity.current
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
}
},
shape = RoundedCornerShape(56.dp),
modifier = modifier
.padding(horizontal = 12.dp)
.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(56.dp) },
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
innerShadow = {
if (isDarkTheme) {
InnerShadow(
radius = 0.5.dp,
offset = DpOffset(1.dp, 1.dp),
color = Color.White.copy(0.6f),
)
} else InnerShadow()
},
layerBlock = {
if (!enabled) return@drawBackdrop
val width = size.width
val height = size.height
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX =
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(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)
val outline = shape.createOutline(size, layoutDirection, this)
val innerShadowOffset = 4f.dp.toPx()
val innerShadowBlurRadius = 4f.dp.toPx()
innerShadowLayer.alpha = progress
innerShadowLayer.renderEffect =
BlurEffect(
innerShadowBlurRadius,
innerShadowBlurRadius,
TileMode.Decal
)
innerShadowLayer.record {
drawOutline(outline, Color.Black.copy(0.2f))
translate(0f, innerShadowOffset) {
drawOutline(
outline,
Color.Transparent,
blendMode = BlendMode.Clear
)
}
}
drawLayer(innerShadowLayer)
if (surfaceColor.isSpecified) {
drawRect(surfaceColor)
}
if (!isDarkTheme) {
drawOutline(
outline = outline,
color = Color.Black.copy(0.4f),
style = Stroke(1f),
)
}
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn(
0.15f,
0.35f
)
)
)
},
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) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
},
effects = {
lens(
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
},
)
.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
)
}
}
}
}
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) }
}
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
scope.launch {
if (enabled) {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
}
.size(with(density) { 48.sp.toDp() }),
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Normal,
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
@Preview(uiMode = UI_MODE_NIGHT_NO, name = "Light")
@Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark")
@Composable
fun StyledIconButtonPreview() {
Box(modifier = Modifier
.height(120.dp)
.width(200.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
RoundedCornerShape(28.dp)
), contentAlignment = Alignment.Center) {
StyledIconButton(
icon = "􀍟",
onClick = { }
)
}
}
@@ -1,178 +0,0 @@
package me.kavishdevar.librepods.presentation.components
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@Composable
fun StyledInputField(
inputState: TextFieldState,
focusRequester: FocusRequester,
placeholder: String = "",
singleLine: Boolean = true,
forceApple: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
if(m3eEnabled) {
TextField(
state = inputState,
placeholder = {
Text(
text = placeholder,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
},
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
else {
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)
)
}
}
}
}
@@ -1,132 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledList(
modifier: Modifier = Modifier,
title: String? = null,
description: String? = null,
content: @Composable StyledListScope.() -> Unit
) {
val scope = StyledListScope()
scope.content()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column (modifier = modifier) {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
.clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
) {
if (m3eEnabled && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(0.8f),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
}
scope.items.forEachIndexed { index, item ->
item(index, scope.items.size)
}
Spacer(modifier = Modifier.height(if(m3eEnabled) 4.dp else 0.dp))
}
}
if (!m3eEnabled && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmallEmphasized,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
}
}
class StyledListScope {
internal val items =
mutableListOf<@Composable (Int, Int) -> Unit>()
fun item(
content: @Composable (index: Int, count: Int) -> Unit
) {
items += content
}
}
@Preview(showBackground = true, wallpaper = GREEN_DOMINATED_EXAMPLE, uiMode = UI_MODE_NIGHT_YES)
@Composable
fun StyledListDemo() {
LibrePodsTheme(
m3eEnabled = true
) {
val backgroundC = MaterialTheme.colorScheme.background
StyledScaffold(
title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}"
) {
Column (
modifier = Modifier.padding(horizontal = 12.dp)
) {
Spacer(modifier = Modifier.height(56.dp))
StyledList(
title = "hello"
) {
for (i in 0..2) {
StyledListItem(
name = i.toString(),
onClick = {}
)
}
val checked = remember { mutableStateOf(false) }
StyledToggle(
label = "Test",
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit mollit anim id est laborum.",
checked = checked.value,
onCheckedChange = { checked.value = it },
)
}
}
}
}
}
@@ -1,409 +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.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.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.graphics.RectangleShape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledListItem(
modifier: Modifier = Modifier,
title: String? = null,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(
if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp))
) {
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
height = height,
enabled = enabled,
index = 0,
count = 1,
orientation = orientation,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
}
@Composable
fun StyledListScope.StyledListItem(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)? = null,
description: String? = null,
enabled: Boolean = onClick != null,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
item { index, count ->
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
enabled = enabled,
index = index,
count = count,
orientation = orientation,
modifier = modifier,
selected = selected,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
enum class ListItemOrientation{
Horizontal,
Vertical
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledListItemContent(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
index: Int,
count: Int,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val isDarkTheme = isSystemInDarkTheme()
val surfaceColor = MaterialTheme.colorScheme.surface
val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim
var backgroundColor by remember { mutableStateOf(surfaceColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
val trailingContentDefault: @Composable () -> Unit = {
if (trailingContent == null) {
if (onClick != null) {
if (selected != null) {
val floatAnimateState by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
} else {
Text(
text = "􀯻",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
modifier = Modifier
.padding(start = if (description != null) 6.dp else 0.dp)
)
}
}
} else {
trailingContent()
}
}
Column (
modifier = Modifier
.background(
animatedBackgroundColor,
when {
(index == 0 && count == 1) -> {
RoundedCornerShape(28.dp)
}
(index == 0) -> {
RoundedCornerShape(
topStart = 28.dp,
topEnd = 28.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
}
(index + 1 == count) -> {
RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomStart = 28.dp,
bottomEnd = 28.dp
)
}
else -> {
RectangleShape
}
}
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor = surfaceDimColor
tryAwaitRelease()
backgroundColor = surfaceColor
}
},
onTap = {
if (enabled) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.ContextClick
)
}
onClick?.invoke()
}
}
)
}
.heightIn(min = height)
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier
.heightIn(min = height)
.padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leadingContent != null) {
leadingContent()
Spacer(modifier = Modifier.width(12.dp))
}
Column (verticalArrangement = Arrangement.Center) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (description != null && orientation == ListItemOrientation.Vertical) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme
)
}
}
Spacer(modifier = Modifier.weight(1f))
if (orientation == ListItemOrientation.Horizontal && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme
)
}
trailingContentDefault()
}
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = if (leadingContent != null) 12.dp else 0.dp)
)
}
}
}
DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
SegmentedListItem(
modifier = modifier.heightIn(min = 64.dp),
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = onClick ?: {},
leadingContent = leadingContent,
trailingContent = {
if (trailingContent == null) {
if (onClick != null) {
if (selected == true) {
Icon(Icons.Default.Check, contentDescription = null)
} else if (selected == null) {
Icon(
Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null
)
}
}
} else {
trailingContent()
}
},
supportingContent = {
if (description != null) Text(
description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
},
content = {
Text(
text = name,
style = MaterialTheme.typography.labelMediumEmphasized,
modifier = Modifier.padding(
top = 4.dp,
bottom = if (description != null) 0.dp else 4.dp
)
)
},
verticalAlignment = Alignment.CenterVertically,
colors = if (onClick == null) {
ListItemDefaults.segmentedColors().run {
copy(
disabledContentColor = contentColor,
disabledSupportingContentColor = supportingContentColor,
disabledTrailingContentColor = trailingContentColor
)
}
} else ListItemDefaults.segmentedColors(),
enabled = onClick != null && enabled,
selected = selected ?: false
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
}
}
}
}
}
@@ -1,295 +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.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledScaffold(
modifier: Modifier = Modifier,
visible: Boolean = true,
title: String,
showBackButton: Boolean = false,
onNavigateBack: () -> Unit = {},
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit
) {
val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true)
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
) {
TopAppBar(
navigationIcon = {
if (showBackButton) {
Row {
Spacer(modifier = Modifier.width(12.dp))
FilledTonalIconButton(
onClick = onNavigateBack,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
}
}
},
title = {
Crossfade(targetState = title) {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
style = MaterialTheme.typography.titleSmall
)
}
},
actions = {
actionButtons.forEach { actionButton ->
actionButton(rememberLayerBackdrop())
}
Spacer(modifier = Modifier.width(12.dp))
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
)
}
},
) { paddingValues ->
Box(
modifier = modifier
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
.fillMaxSize()
.hazeSource(hazeState)
) {
content()
}
}
}
DesignSystem.Apple -> {
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.then(
if (!isDarkTheme) Modifier.shadow(
elevation = 36.dp,
shape = RoundedCornerShape(52.dp),
ambientColor = Color.Black,
spotColor = Color.Black
) else Modifier
)
.clip(RoundedCornerShape(52.dp))
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = startPadding, end = endPadding)
) {
val backdrop = rememberLayerBackdrop()
val bgColor = MaterialTheme.colorScheme.surfaceContainer
AnimatedVisibility(
visible = showBackButton,
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, start = 8.dp)
.align(Alignment.TopStart)
) {
StyledIconButton(
onClick = onNavigateBack,
icon = "􀯶",
backdrop = backdrop
)
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier
.zIndex(2f)
.height(64.dp + topPadding)
.fillMaxWidth()
.layerBackdrop(backdrop)
){
Box(
modifier = Modifier.hazeEffect(
state = hazeState,
) {
backgroundColor = bgColor
tints = listOf(
HazeTint(
if (isDarkTheme) Color.Black.copy(0.55f) else Color(
0xFFF2F2F7
).copy(alpha = 0.85f)
)
)
blurRadius = 6.dp
}
) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding + 12.dp))
Crossfade(targetState = title) {
Text(
text = it,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
}
}
AnimatedVisibility(
visible = visible && actionButtons.isNotEmpty(),
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
) {
Row{
actionButtons.forEach { actionButton ->
actionButton(backdrop)
}
}
}
Box(
modifier = modifier
.hazeSource(hazeState)
.fillMaxSize()
) {
content()
}
}
}
}
}
}
@@ -1,808 +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 android.annotation.SuppressLint
import android.content.res.Configuration
import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
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
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
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.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun rememberMomentumAnimation(
maxScale: Float,
progressAnimationSpec: FiniteAnimationSpec<Float> =
spring(1f, 1000f, 0.01f),
velocityAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.5f, 250f, 5f),
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.4f, 400f, 0.01f),
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.6f, 400f, 0.01f)
): MomentumAnimation {
val animationScope = rememberCoroutineScope()
return remember(
maxScale,
animationScope,
progressAnimationSpec,
velocityAnimationSpec,
scaleXAnimationSpec,
scaleYAnimationSpec
) {
MomentumAnimation(
maxScale = maxScale,
animationScope = animationScope,
progressAnimationSpec = progressAnimationSpec,
velocityAnimationSpec = velocityAnimationSpec,
scaleXAnimationSpec = scaleXAnimationSpec,
scaleYAnimationSpec = scaleYAnimationSpec
)
}
}
class MomentumAnimation(
val maxScale: Float,
private val animationScope: CoroutineScope,
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
) {
private val velocityTracker = VelocityTracker()
private val progressAnimation = Animatable(0f)
private val velocityAnimation = Animatable(0f)
private val scaleXAnimation = Animatable(1f)
private val scaleYAnimation = Animatable(1f)
val progress: Float get() = progressAnimation.value
val velocity: Float get() = velocityAnimation.value
val scaleX: Float get() = scaleXAnimation.value
val scaleY: Float get() = scaleYAnimation.value
var isDragging: Boolean by mutableStateOf(false)
private set
val modifier: Modifier = Modifier.pointerInput(Unit) {
inspectDragGestures(
onDragStart = {
isDragging = true
velocityTracker.resetTracking()
startPressingAnimation()
},
onDragEnd = { change ->
isDragging = false
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
velocityTracker.addPointerInputChange(change)
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
},
onDragCancel = {
isDragging = false
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
}
) { change, _ ->
isDragging = true
velocityTracker.addPointerInputChange(change)
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
}
}
private fun updateVelocity(velocity: Velocity) {
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
}
private fun settleVelocity() {
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
}
fun startPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
}
}
fun endPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
}
}
}
@SuppressLint("UnrememberedMutableState")
@Composable
fun StyledSlider(
label: String? = null,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List<Float> = emptyList(),
snapThreshold: Float = 0.05f,
startIcon: String? = null,
endIcon: String? = null,
startLabel: String? = null,
endLabel: String? = null,
independent: Boolean = false,
description: String? = null,
enabled: Boolean = true,
index: Int = 0,
count: Int = 1
) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
label?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmallEmphasized,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
)
}
SegmentedListItem(
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = {},
enabled = enabled,
modifier = Modifier.heightIn(min = 58.dp),
content = {
Column(
modifier = Modifier.fillMaxWidth()
) {
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier.fillMaxWidth()
) {
startLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall
)
}
Spacer(Modifier.weight(1f))
endLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
},
supportingContent = {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
startIcon?.let {
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
Spacer(Modifier.width(12.dp))
}
Slider(
modifier = Modifier.weight(1f),
value = value,
onValueChange = { newValue ->
val snapped =
if (snapPoints.isNotEmpty()) {
snapIfClose(
newValue,
snapPoints,
snapThreshold
)
} else {
newValue
}
onValueChange(snapped)
},
valueRange = valueRange,
enabled = enabled
)
endIcon?.let {
Spacer(Modifier.width(12.dp))
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
}
}
}
}
)
if (index + 1 != count) {
Spacer(
modifier = Modifier.height(2.dp)
)
}
}
}
DesignSystem.Apple -> {
val backgroundColor =
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isDarkTheme = isSystemInDarkTheme()
val trackColor =
if (isDarkTheme) Color(0xFF787880).copy(0.36f)
else Color(0xFF787878).copy(0.2f)
val accentColor =
if (enabled) {
if (isDarkTheme) Color(0xFF0091FF)
else Color(0xFF0088FF)
} else {
trackColor
}
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
val fraction by derivedStateOf {
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f)
}
val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) }
val trackPositionState = remember { mutableFloatStateOf(0f) }
val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
val content = @Composable {
Box(
Modifier
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
) {
Box(
Modifier
.padding(vertical = 4.dp)
.layerBackdrop(sliderBackdrop)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = startLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = endLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.then(
if (startIcon == null && endIcon == null) Modifier.padding(
horizontal = 8.dp
) else Modifier
),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
if (startIcon != null) {
Text(
text = startIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
startIconWidthState.floatValue =
it.size.width.toFloat()
}
)
}
Box(
Modifier
.weight(1f)
.onSizeChanged {
trackWidthState.floatValue = it.width.toFloat()
}
.onGloballyPositioned {
trackPositionState.floatValue =
it.positionInParent().y + it.size.height / 2f
}
) {
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(trackColor)
.height(6f.dp)
.fillMaxWidth()
)
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(accentColor)
.height(6f.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val fraction = fraction
val width =
(fraction * constraints.maxWidth).fastRoundToInt()
layout(width, placeable.height) {
placeable.place(0, 0)
}
}
)
}
if (endIcon != null) {
Text(
text = endIcon,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
endIconWidthState.floatValue =
it.size.width.toFloat()
}
)
}
}
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(
modifier = Modifier.height(4.dp)
)
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (snapPoints.isNotEmpty()) {
val trackWidth =
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
density
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
density
) { 22.dp.toPx() }
val startOffset =
if (startIcon != null) startIconWidthState.floatValue + with(
density
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
Box(
Modifier
.fillMaxWidth()
) {
snapPoints.forEach { point ->
val pointFraction =
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f)
Box(
Modifier
.graphicsLayer {
translationX =
startOffset + pointFraction * trackWidth - 4.dp.toPx()
}
.size(2.dp)
.background(
trackColor,
CircleShape
)
)
}
}
}
}
}
}
}
Box(
Modifier
.graphicsLayer {
val startOffset =
if (startIcon != null)
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
else
with(density) { 8.dp.toPx() }
translationX =
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
.fastCoerceIn(
startOffset - size.width / 4f,
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
)
translationY =
if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(
density
) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(
density
) { 8.dp.toPx() }
}
.then(
if (enabled) {
Modifier
.draggable(
rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) {
val targetFraction =
fraction + delta / trackWidth
val targetValue =
lerp(
valueRange.start,
valueRange.endInclusive,
targetFraction
)
.fastCoerceIn(
valueRange.start,
valueRange.endInclusive
)
snapPoints.forEach { snap ->
if ((lastDragValue < snap && targetValue >= snap) ||
(snap in targetValue..<lastDragValue)
) {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
lastDragValue = targetValue
val snappedValue =
if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold,
) else targetValue
onValueChange(snappedValue)
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
lastDragValue = value
},
onDragStopped = {
onValueChange((value * 100).roundToInt() / 100f)
}
)
.then(momentumAnimation.modifier)
.drawBackdrop(
rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = momentumAnimation.progress
Highlight.Ambient.copy(alpha = progress)
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.05f)
)
},
innerShadow = {
val progress = momentumAnimation.progress
InnerShadow(
radius = 4f.dp * progress,
alpha = progress
)
},
layerBlock = {
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(
-0.15f,
0.15f
)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(
-0.15f,
0.15f
)
},
onDrawSurface = {
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
},
effects = {
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
lens(
refractionHeight = 6f.dp.toPx() * progress,
refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
)
}
)
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp)
)
}
}
if (independent) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
if (label != null) {
Text(
text = label,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = labelTextColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.heightIn(min = 58.dp),
contentAlignment = Alignment.Center
) {
content()
}
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
}
} else {
if (label != null) Log.w(
"StyledSlider",
"Label is ignored when independent is false"
)
if (description != null) Log.w(
"StyledSlider",
"Description is ignored when independent is false"
)
content()
}
}
}
}
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
val nearest = points.minByOrNull { abs(it - value) } ?: value
return if (abs(nearest - value) <= threshold) nearest else value
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
@Composable
fun StyledSliderPreview() {
val a = remember { mutableFloatStateOf(0.5f) }
LibrePodsTheme(
m3eEnabled = true
) {
StyledScaffold(
title = "test",
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(72.dp))
StyledSlider(
value = a.floatValue,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
)
StyledSlider(
label = "Small label",
description = "This is a somewhat long descriptionRes",
value = a.floatValue,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
)
}
}
}
}
@@ -1,387 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.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.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
header: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column(modifier = Modifier.padding(vertical = 12.dp)) {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(
if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp))
) {
if (m3eEnabled) {
StyledToggleContent(
label = label,
description = description,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = 0,
count = 1,
header = header
)
} else {
StyledToggleContent(
label = label,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = 0,
count = 1
)
}
}
if (description != null && !m3eEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description, style = TextStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
), modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
}
@Composable
fun StyledListScope.StyledToggle(
label: String,
description: String? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
item { index, count ->
StyledToggleContent(
label = label,
description = description,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = index,
count = count
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledToggleContent(
label: String,
description: String? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
index: Int,
count: Int,
header: Boolean = false
) {
val currentChecked by rememberUpdatedState(checked)
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
if (m3eEnabled) {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
SegmentedListItem(
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = { onCheckedChange(!currentChecked) },
trailingContent = {
Switch(
checked = currentChecked,
onCheckedChange = onCheckedChange,
modifier = Modifier.padding(end = if (header) 8.dp else 0.dp),
enabled = enabled
)
},
supportingContent = description?.let {
{
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp)
.padding(horizontal = if (header) 8.dp else 0.dp),
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
)
}
},
content = {
Text(
text = label,
style = MaterialTheme.typography.labelMediumEmphasized,
modifier = Modifier
.padding(
top = if (header) 8.dp else 4.dp,
bottom = if (header) 2.dp else 4.dp
)
.padding(horizontal = if (header) 8.dp else 0.dp),
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
)
},
enabled = enabled,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = 64.dp),
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = textColor,
)
if (description != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = textColor.copy(0.8f)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
onCheckedChange(it)
}
}
)
}
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp,end = 12.dp)
)
}
}
}
}
@Preview(name = "List", group = "Apple")
@Composable
fun StyledToggleAppleListPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = false) {
StyledList {
StyledToggle(
label = "Apple Styled List",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
}
@Preview(name = "Normal", group = "Apple")
@Composable
fun StyledToggleApplePreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = false) {
StyledToggle(
label = "Apple",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
@Preview(name = "List", group = "Apple")
@Composable
fun StyledToggleM3EListPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = true) {
StyledList {
StyledToggle(
label = "Apple Styled List",
// description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
}
@Preview(name = "Normal", group = "Material")
@Composable
fun StyledToggleM3EPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = true) {
StyledToggle(
label = "Material",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
@@ -1,324 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CallControlScreen
import me.kavishdevar.librepods.presentation.screens.EqualizerRoute
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LoadingScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AppNavGraph(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
backStack: SnapshotStateList<Screen>,
airPodsViewModel: AirPodsViewModel,
) {
val navigate: (Screen) -> Unit = { screen ->
backStack.add(screen)
}
fun navigateToPurchase() {
navigate(Screen.Purchase)
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
SharedTransitionLayout {
NavDisplay(
sharedTransitionScope = this,
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
},
entryProvider = { screen ->
when (screen) {
Screen.Onboarding ->
NavEntry(screen) {
OnboardingScreen {
onboardingComplete()
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings)
backStack.remove(screen)
}
}
Screen.AirPodsSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AirPodsSettingsRoute(
viewModel = airPodsViewModel,
navigateToRename = { navigate(Screen.Rename) },
navigateToHearingProtection = { navigate(Screen.HearingProtection) },
navigateToHearingAid = { navigate(Screen.HearingAid) },
navigateToLeftLongPress = {
navigate(
Screen.LongPress("Left")
)
},
navigateToRightLongPress = {
navigate(
Screen.LongPress("Right")
)
},
navigateToPurchase = { navigate(Screen.Purchase) },
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) },
navigateToEqualizer = { navigate(Screen.Equalizer) },
navigateToHeadTracking = { navigate(Screen.HeadTracking) },
navigateToAccessibility = { navigate(Screen.Accessibility) },
navigateToVersion = { navigate(Screen.VersionInfo) },
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
)
}
Screen.Rename ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
RenameScreen(airPodsViewModel)
}
Screen.AppSettings ->
NavEntry(screen) {
val vm: AppSettingsViewModel = viewModel()
AppSettingsScreen(
viewModel = vm,
navigateToPurchase = ::navigateToPurchase,
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }
)
}
Screen.Troubleshooting ->
NavEntry(screen) {
TroubleshootingScreen()
}
Screen.HeadTracking ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase)
}
Screen.Accessibility ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AccessibilitySettingsScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) }
)
}
Screen.TransparencyCustomization ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
TransparencySettingsScreen(airPodsViewModel)
}
Screen.HearingAid ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidScreen(
viewModel = airPodsViewModel,
onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) },
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) },
)
}
Screen.HearingAidAdjustments ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidAdjustmentsScreen(airPodsViewModel)
}
Screen.AdaptiveStrength ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase)
}
// Screen.CameraControl ->
// NavEntry(screen) {
// CameraControlScreen(airPodsViewModel)
// }
Screen.OpenSourceLicenses ->
NavEntry(screen) {
OpenSourceLicensesScreen()
}
Screen.UpdateHearingTest ->
NavEntry(screen) {
UpdateHearingTestRoute(airPodsViewModel)
}
Screen.VersionInfo ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
VersionScreen(airPodsViewModel)
}
Screen.HearingProtection ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingProtectionScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase
)
}
Screen.Purchase ->
NavEntry(screen) {
val vm: PurchaseViewModel = viewModel()
PurchaseScreen(vm, backStack)
}
Screen.Equalizer ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
EqualizerRoute(airPodsViewModel)
}
is Screen.LongPress ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
LongPress(
viewModel = airPodsViewModel,
name = screen.bud,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
CallControlScreen(
viewModel = airPodsViewModel,
action = screen.action,
onCallControlValueChanged = { flipped ->
airPodsViewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
0x00,
0x03
)
)
}
)
}
is Screen.MicrophoneSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
MicrophoneSettingsRoute(viewModel = airPodsViewModel)
}
is Screen.ReleaseNotes ->
NavEntry(screen) {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.AirPodsSettings)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
}
},
transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 }
},
popTransitionSpec = {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
},
predictivePopTransitionSpec = { swipeEdge ->
if (m3eEnabled) {
val enterOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ -it / 6 }
} else {
{ it / 6 }
}
val exitOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ it / 8 }
} else {
{ -it / 8 }
}
fadeIn(
animationSpec = tween(250)
) +
slideInHorizontally(
initialOffsetX = enterOffset,
animationSpec = tween(250)
) togetherWith
fadeOut(
targetAlpha = 0.75f,
animationSpec = tween(250)
) +
scaleOut(
targetScale = 0.85f,
animationSpec = tween(250)
) +
slideOutHorizontally(
targetOffsetX = exitOffset,
animationSpec = tween(250)
)
} else {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
}
},
)
}
}
@@ -1,158 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import android.util.Log
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.kyant.backdrop.backdrops.LayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun NavigationRoot(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
airPodsViewModel: AirPodsViewModel
) {
val backStack = remember {
mutableStateListOf(
when {
showOnboarding -> Screen.Onboarding
showReleaseNotes -> Screen.ReleaseNotes
else -> Screen.AirPodsSettings
}
)
}
val currentScreen = backStack.last()
val state by airPodsViewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val title = when (currentScreen) {
Screen.Onboarding -> ""
Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name)
Screen.Accessibility -> stringResource(R.string.accessibility)
Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
Screen.AppSettings -> stringResource(R.string.settings)
// Screen.CameraControl -> stringResource(R.string.camera_control)
Screen.Equalizer -> stringResource(R.string.equalizer)
Screen.HeadTracking -> stringResource(R.string.head_tracking)
Screen.HearingAid -> stringResource(R.string.hearing_aid)
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
is Screen.LongPress -> currentScreen.bud
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
Screen.Rename -> stringResource(R.string.name)
Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
Screen.VersionInfo -> stringResource(R.string.version)
is Screen.CallControl -> currentScreen.action
Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
Screen.ReleaseNotes -> ""
}
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
val actionButtons = when (currentScreen) {
Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconButton(
onClick = { backStack.add(Screen.AppSettings) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = { backStack.add(Screen.AppSettings) },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}
}
)
Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconToggleButton(
checked = state.headTrackingActive,
onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = {
if (!state.headTrackingActive) {
airPodsViewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
airPodsViewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (state.headTrackingActive) "􀊅" else "􀊃",
backdrop = scaffoldBackdrop
)
}
}
)
else -> listOf()
}
StyledScaffold(
visible = currentScreen.showTopBar,
title = title,
showBackButton = backStack.size > 1,
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
actionButtons = actionButtons
) {
AppNavGraph(
showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown,
showOnboarding = showOnboarding,
onboardingComplete = onboardingComplete,
backStack = backStack,
airPodsViewModel = airPodsViewModel,
)
}
}
@@ -1,84 +0,0 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen: NavKey {
val showTopBar: Boolean
get() = true
@Serializable
data object Onboarding: Screen {
override val showTopBar: Boolean = false
}
@Serializable
data object AirPodsSettings: Screen
@Serializable
data object Rename: Screen
@Serializable
data object AppSettings: Screen
@Serializable
data object Troubleshooting: Screen
@Serializable
data object HeadTracking: Screen
@Serializable
data object Accessibility: Screen
@Serializable
data object TransparencyCustomization: Screen
@Serializable
data object HearingAid: Screen
@Serializable
data object HearingAidAdjustments: Screen
@Serializable
data object AdaptiveStrength: Screen
// @Serializable
// data object CameraControl: Screen
@Serializable
data object OpenSourceLicenses: Screen
@Serializable
data object UpdateHearingTest: Screen
@Serializable
data object VersionInfo: Screen
@Serializable
data object HearingProtection: Screen
@Serializable
data object Purchase: Screen
@Serializable
data object Equalizer: Screen
@Serializable
data class LongPress(
val bud: String
): Screen
@Serializable
data class CallControl(
val action: String
): Screen
@Serializable
data object MicrophoneSettings: Screen
@Serializable
data object ReleaseNotes: Screen {
override val showTopBar: Boolean = false
}
}
@@ -1,585 +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 me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private var phoneMediaDebounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
@Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
val state by viewModel.uiState.collectAsState()
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 m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
)
}
val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
)
val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150.milliseconds)
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}"
)
}
}
}
StyledList(
title = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description)
) {
pressSpeedOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedPressSpeed == label,
onClick = {
selectedPressSpeed = label
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = value
)
}
)
}
}
StyledList(
title = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description)
) {
pressAndHoldDurationOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedPressAndHoldDuration == label,
onClick = {
selectedPressAndHoldDuration = label
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = value
)
}
)
}
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = state.isPremium
)
}
if (!hearingAidEnabled && state.vendorIdHook) {
StyledListItem(
name = stringResource(R.string.customize_transparency_mode),
onClick = navigateToTransparencyCustomization,
enabled = state.isPremium
)
}
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
LaunchedEffect(toneVolumeValue) {
snapshotFlow {
toneVolumeValue.floatValue
}
.debounce(100.milliseconds)
.collect {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
}
}
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue.floatValue,
onValueChange = {
toneVolumeValue.floatValue = it
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9",
independent = true,
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium
)
StyledList(
title = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description)
) {
volumeSwipeSpeedOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedVolumeSwipeSpeed == label,
onClick = {
selectedVolumeSwipeSpeed = label
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = value
)
}
)
}
}
}
// if (!hearingAidEnabled && XposedState.isAvailable) {
// Text(
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
// fontSize = 14.sp,
// fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro))
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// )
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(vertical = 0.dp)
// ) {
// val darkModeLocal = isSystemInDarkTheme()
//
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
// var phoneBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val phoneAnimatedBackgroundColor by animateColorAsState(
// targetValue = phoneBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(phoneAnimatedBackgroundColor, phoneShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease()
// phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// phoneEQEnabled.value = !phoneEQEnabled.value
// })
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically) {
// Text(
// stringResource(R.string.phone),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = phoneEQEnabled.value,
// onCheckedChange = { phoneEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
//
// HorizontalDivider(
// thickness = 1.dp, color = Color(0x40888888)
// )
//
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
// var mediaBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val mediaAnimatedBackgroundColor by animateColorAsState(
// targetValue = mediaBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(mediaAnimatedBackgroundColor, mediaShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease()
// mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// 0xFFFFFFFF
// )
// mediaEQEnabled.value = !mediaEQEnabled.value
// })
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically) {
// Text(
// stringResource(R.string.media),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = mediaEQEnabled.value,
// onCheckedChange = { mediaEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
// }
//
//// EQ Settings. Don't seem to have an effect?
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
// ) {
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
//
// for (i in 0 until 8) {
// val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
// Row(
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
//
// Slider(
// value = eqPhoneValue.floatValue,
// onValueChange = { newVal ->
// eqPhoneValue.floatValue = newVal
// val newEQ = phoneMediaEQ.value.copyOf()
// newEQ[i] = eqPhoneValue.floatValue
// phoneMediaEQ.value = newEQ
// },
// valueRange = 0f..100f,
// modifier = Modifier
// .fillMaxWidth(0.9f)
// .height(36.dp),
// colors = SliderDefaults.colors(
// thumbColor = thumbColor,
// activeTrackColor = activeTrackColor,
// inactiveTrackColor = trackColor
// ),
// thumb = {
// Box(
// modifier = Modifier
// .size(24.dp)
// .shadow(4.dp, CircleShape)
// .background(thumbColor, CircleShape)
// )
// },
// track = {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(12.dp),
// contentAlignment = Alignment.CenterStart
// ) {
// Box(
// modifier = Modifier
// .fillMaxWidth()
// .height(4.dp)
// .background(trackColor, RoundedCornerShape(4.dp))
// )
// Box(
// modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp)
// .background(
// activeTrackColor, RoundedCornerShape(4.dp)
// )
// )
// }
// })
//
// Text(
// text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(top = 4.dp)
// )
// }
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,122 +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.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
job?.cancel()
job = scope.launch {
delay(150)
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
byteArrayOf((100 - it).toInt().toByte())
)
}
},
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,613 +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 android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
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.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.MaterialTypography
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(
viewModel: AppSettingsViewModel = viewModel(),
navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit,
navigateToReleaseNotesScreen: () -> Unit
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val contactBottomSheet = remember { mutableStateOf(false) }
val subjectState = remember { TextFieldState() }
val descriptionState = remember { TextFieldState() }
val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
if (!state.isPremium && state.connectionSuccessful) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.timeUntilFOSSPremiumExpiry > 0L) {
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
StyledToggle(
title = stringResource(R.string.appearance),
label = stringResource(R.string.use_material3e),
checked = state.m3eEnabled,
onCheckedChange = viewModel::setm3eEnabled,
// enabled = state.isPremium
)
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
StyledList(title = stringResource(R.string.popup_animations)) {
StyledToggle(
label = stringResource(R.string.show_bottom_sheet_popup),
description = stringResource(R.string.show_bottom_sheet_popup_description),
checked = state.showBottomSheetPopup,
onCheckedChange = viewModel::setShowBottomSheetPopup,
)
StyledToggle(
label = stringResource(R.string.show_island_popup),
description = stringResource(R.string.show_island_popup_description),
checked = state.showIslandPopup,
onCheckedChange = viewModel::setShowIslandPopup,
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList (title = stringResource(R.string.conversational_awareness)) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true,
enabled = state.isPremium
)
// if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp))
//
// StyledListItem(
// to = "",
// titleRes = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package),
// navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// descriptionRes = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
)
}
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
StyledList(title = stringResource(R.string.takeover_phone_state)) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
enabled = state.isPremium
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
enabled = state.isPremium
)
}
StyledToggle(
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.customizations_unavailable),
style = MaterialTypography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
)
}
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource(
R.string.requires_xposed
)
})",
description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook,
onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled)
}
)
}
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
StyledList {
StyledListItem(
name = stringResource(R.string.troubleshooting),
onClick = navigateToTroubleshooting,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
StyledList(title = stringResource(R.string.contact)) {
StyledListItem(
name = stringResource(R.string.email),
onClick = { contactBottomSheet.value = true },
)
StyledListItem(
name = stringResource(R.string.discord),
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
)
StyledListItem(
name = stringResource(R.string.github_issues),
onClick = {
val appVersion =
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
val appSource = Uri.encode(
when {
BuildConfig.PLAY_BUILD -> "Play"
else -> "GitHub"
}
)
val url = "https://github.com/kavishdevar/librepods/issues/new" +
"?template=01-bug-report-android.yml" +
"&app-source=$appSource" +
"&app-version=$appVersion" +
"&device=$device" +
"&android-version=$androidVersion"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
},
)
}
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard(navigateToReleaseNotesScreen)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.open_source_licenses),
onClick = navigateToOpenSourceLicenses,
)
Spacer(modifier = Modifier.height(bottomPadding))
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}, text = {
Column {
Text(
stringResource(R.string.enter_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
0xFF3C6DF5
),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (state.cameraPackageError != null) {
Text(
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
label = { Text(stringResource(R.string.custom_camera_package)) })
}
}, confirmButton = {
val successText = stringResource(R.string.custom_camera_package_set_success)
TextButton(
onClick = {
viewModel.saveCameraPackage()
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
}) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}, dismissButton = {
TextButton(
onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
})
}
}
StyledBottomSheet(
visible = contactBottomSheet.value,
onDismiss = { contactBottomSheet.value = false },
backdrop = backdrop
) { innerBackdrop, progress ->
val animatedPadding = lerp(16.dp, 2.dp, progress)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = animatedPadding)
.padding(bottom = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StyledIconButton(
icon = "\uDBC0\uDD84",
backdrop = innerBackdrop,
onClick = { contactBottomSheet.value = false }
)
Text (
text = stringResource(R.string.describe_your_issue),
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
StyledIconButton(
icon = "\uDBC0\uDE1F",
backdrop = innerBackdrop,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
onClick = {
contactBottomSheet.value = false
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
putExtra(
Intent.EXTRA_TEXT,
"${descriptionState.text}" +
"\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
subjectState.clearText()
descriptionState.clearText()
}
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledInputField(
inputState = subjectState,
focusRequester = subjectFocusRequester,
placeholder = stringResource(R.string.subject),
forceApple = true
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false,
forceApple = true
)
}
}
}
@@ -1,97 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
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 pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
val muteUnmuteText = stringResource(R.string.mute_unmute)
var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) }
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
val flippedValue = action != muteUnmuteText
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = pressOnceText,
selected = pressOnceIsAction,
onClick = {
singlePressAction = pressOnceText
onCallControlValueChanged(flippedValue)
}
)
StyledListItem(
name = pressTwiceText,
selected = !pressOnceIsAction,
onClick = {
singlePressAction = pressTwiceText
onCallControlValueChanged(!flippedValue)
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,81 +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
//@Composable
//fun CameraControlScreen(viewModel: AirPodsViewModel) {
// val context = LocalContext.current
// val currentCameraAction by viewModel.cameraAction.collectAsState()
//
// fun isAppListenerServiceEnabled(context: Context): Boolean {
// val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
// val enabledServices =
// am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
// val serviceComponent = ComponentName(context, AppListenerService::class.java)
// return enabledServices.any {
// it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
// it.resolveInfo.serviceInfo.name == serviceComponent.className
// }
// }
//
// fun handleSelection(action: StemPressType?) {
// if (action != null && !isAppListenerServiceEnabled(context)) {
// context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
// } else {
// viewModel.setCameraAction(action)
// }
// }
//
// val cameraOptions = remember(currentCameraAction) {
// listOf(
// SelectItem(
// name = "Off",
// selected = currentCameraAction == null,
// onClick = { handleSelection(null) }
// ),
// SelectItem(
// name = "Press once",
// selected = currentCameraAction == StemPressType.SINGLE_PRESS,
// onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
// ),
// SelectItem(
// name = "Press and hold AirPods",
// selected = currentCameraAction == StemPressType.LONG_PRESS,
// onClick = { handleSelection(StemPressType.LONG_PRESS) }
// )
// )
// }
//
// val backdrop = rememberLayerBackdrop()
//
// StyledScaffold(
// titleRes = stringResource(R.string.camera_control)
// ) { spacerHeight ->
// Column(
// modifier = Modifier
// .fillMaxSize()
// .layerBackdrop(backdrop)
// .padding(horizontal = 16.dp),
// verticalArrangement = Arrangement.spacedBy(16.dp)
// ) {
// Spacer(modifier = Modifier.height(spacerHeight))
// StyledSelectList(items = cameraOptions)
// }
// }
//}
@@ -1,762 +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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun EqualizerRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setCustomEqEnabled = viewModel::setCustomEqEnabled,
setCustomEq = viewModel::setCustomEq
)
}
}
@OptIn(FlowPreview::class)
@Composable
fun EqualizerScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setCustomEqEnabled: (Boolean) -> Unit,
setCustomEq: (Int, Int, Int) -> Unit
) {
val customEq = state.customEq
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.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))
)
}
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()
setCustomEq(low, mid, high)
}
}
Spacer(modifier = Modifier.height(topPadding))
val enabled = customEq.isEnabled()
StyledList {
StyledListItem(
name = stringResource(R.string.recommended),
selected = !enabled,
onClick = { setCustomEqEnabled(false) }
)
StyledListItem(
name = stringResource(R.string.custom),
selected = enabled,
onClick = { setCustomEqEnabled(true) }
)
}
Spacer(modifier = Modifier.height(12.dp))
Crossfade (
customEq.isEnabled()
) { visible ->
Column(
modifier = Modifier
.fillMaxWidth()
.visible(visible),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EqualizerCard(
lowOffset = offsets[0],
midOffset = offsets[1],
highOffset = offsets[2]
)
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,
enabled = resetButtonEnabled.value
) {
Text(
text = stringResource(R.string.reset),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
fun EqualizerCard(
lowOffset: MutableState<Float>,
midOffset: MutableState<Float>,
highOffset: MutableState<Float>
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, 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)
)
)
}
}
}
}
}
val backgroundColor = MaterialTheme.colorScheme.surface
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val canvasWidth = size.width
drawLine(
color = backgroundColor,
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(lowOffset.value),
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 8f
)
val lowToMidPath = Path()
lowToMidPath.moveTo(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
)
lowToMidPath.cubicTo(
x1 = canvasWidth * 1 / 6f + 108.dp.value,
y1 = lowOffset.value + maxOffset,
x2 = canvasWidth * 0.5f - 108.dp.value,
y2 = midOffset.value + maxOffset,
x3 = canvasWidth * 0.5f,
y3 = midOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = lowToMidPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
lowOffset.value,
midOffset.value
),
path = lowToMidPath,
style = Stroke(width = 8f)
)
val midToHighPath = Path()
midToHighPath.moveTo(
x = 0.5f * canvasWidth,
y = midOffset.value + maxOffset
)
midToHighPath.cubicTo(
x1 = canvasWidth * 0.5f + 108.dp.value,
y1 = midOffset.value + maxOffset,
x2 = canvasWidth * 5 / 6f - 108.dp.value,
y2 = highOffset.value + maxOffset,
x3 = canvasWidth * 5 / 6f,
y3 = highOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = midToHighPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
midOffset.value,
highOffset.value
),
path = midToHighPath,
style = Stroke(width = 8f)
)
drawLine(
color = backgroundColor,
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(highOffset.value),
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + 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 = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.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(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
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(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
CircleShape
)
.border(
2.5.dp,
MaterialTheme.colorScheme.surfaceContainer,
CircleShape
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
when (i) {
0 -> {
lowOffset.value =
(lowOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
1 -> {
midOffset.value =
(midOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
2 -> {
highOffset.value =
(highOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
}
},
onDragStarted = {
pressed.value = true
},
onDragStopped = {
pressed.value = false
}
)
)
}
}
}
}
}
}
}
}
@Preview(name = "Apple")
@Composable
fun EqualizerScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = demoState,
setCustomEqEnabled = { },
setCustomEq = {_, _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun EqualizerScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
val state = remember { mutableStateOf(demoState) }
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state.value,
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
)
}
}
}
@@ -1,405 +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/>.
*/
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) {
viewModel.startHeadTracking()
onDispose {
viewModel.stopHeadTracking()
}
}
val isDarkTheme = isSystemInDarkTheme()
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Column (
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
StyledToggle(
label = "Head Gestures",
checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium || state.headGesturesEnabled,
description = stringResource(R.string.head_gestures_details),
header = true
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
"Velocity",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
Plot()
Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(gestureText) {
if (gestureText.isNotEmpty()) {
lastClickTime = System.currentTimeMillis()
delay(3000)
if (System.currentTimeMillis() - lastClickTime >= 3000) {
shouldExplode = true
}
}
}
}
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton(
onClick = {
gestureText = gestureTextValue
coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
}
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
maxScale = 0.05f
) {
Text(
"Test Head Gestures",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.onSecondaryContainer
),
)
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) {
AnimatedContent(
targetState = gestureText,
transitionSpec = {
(fadeIn(
animationSpec = tween(300)
) + slideInVertically(
initialOffsetY = { 40 },
animationSpec = tween(300)
)).togetherWith(fadeOut(animationSpec = tween(150)))
}
) { text ->
if (shouldExplode) {
LaunchedEffect(Unit) {
CoroutineScope(coroutineScope.coroutineContext).launch {
delay(750)
gestureText = ""
}
}
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
color = MaterialTheme.colorScheme.onBackground
)
} else {
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
private fun Plot() {
val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
val darkTheme = isSystemInDarkTheme()
var maxAbs by remember { mutableFloatStateOf(1000f) }
LaunchedEffect(acceleration) {
points.add(Pair(acceleration.horizontal, acceleration.vertical))
if (points.size > maxPoints) {
points.removeAt(0)
}
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
maxAbs = maxOf(currentMax * 1.2f, 1000f)
}
Card(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
shape = RoundedCornerShape(28.dp)
) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val width = size.width
val height = size.height
val xScale = width / maxPoints
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
val zeroY = height / 2
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
for (i in 0..maxPoints step 10) {
val x = i * xScale
drawLine(
color = gridColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 1.dp.toPx()
)
}
val gridStep = maxAbs / 4
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
val y = zeroY - value * yScale
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1.dp.toPx()
)
}
drawLine(
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 1.5f.dp.toPx()
)
if (points.size > 1) {
for (i in 0 until points.size - 1) {
val x1 = i * xScale
val x2 = (i + 1) * xScale
drawLine(
color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx()
)
}
}
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
}
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
drawText("0", 30.dp.toPx(), height/2, paint)
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
}
val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2
drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
}
}
}
}
}
@@ -1,225 +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 android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
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.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
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)
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description)
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.brighter),
independent = true,
)
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
StyledToggle(
label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
description = stringResource(R.string.conversation_boost_description)
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,243 +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 android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
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 com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
val verticalScrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop()
val showDialog = remember { mutableStateOf(false) }
val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember {
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
// val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false
}
initialLoad.value = false
}
// fun onAdjustPhoneChange(value: Boolean) {
// // TODO
// }
// fun onAdjustMediaChange(value: Boolean) {
// // TODO
// }
StyledList (title = stringResource(R.string.hearing_aid)) {
StyledToggle(
label = stringResource(R.string.hearing_aid),
checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
)
StyledListItem(
name = stringResource(R.string.adjustments),
onClick = onNavigateHearingAidAdjustments,
)
}
Text(
text = stringResource(R.string.hearing_aid_description),
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
StyledListItem(
name = stringResource(R.string.update_hearing_test),
onClick = onNavigateHearingTest,
)
// not implemented yet
// StyledToggle(
// titleRes = stringResource(R.string.media_assist),
// label = stringResource(R.string.media_assist),
// checkedState = mediaAssistEnabled,
// independent = true,
// descriptionRes = stringResource(R.string.media_assist_description)
// )
// Spacer(modifier = Modifier.height(8.dp))
// Column (
// modifier = Modifier
// .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp))
// ) {
// StyledToggle(
// label = stringResource(R.string.adjust_media),
// checkedState = adjustMediaEnabled,
// onCheckedChange = { onAdjustMediaChange(it) },
// independent = false
// )
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 12.dp)
// )
// StyledToggle(
// label = stringResource(R.string.adjust_calls),
// checkedState = adjustPhoneEnabled,
// onCheckedChange = { onAdjustPhoneChange(it) },
// independent = false
// )
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
ConfirmationDialog(
showDialog = showDialog,
title = "Enable Hearing Aid",
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
confirmText = "Enable",
dismissText = "Cancel",
onConfirm = {
showDialog.value = false
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
}
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch {
try {
if (state.hearingAidData.isEmpty()) {
Log.w(TAG, "read failed")
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) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
}
},
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
backdrop = backdrop
)
}
@@ -1,118 +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.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
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.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = MaterialTheme.colorScheme.primary
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -1,35 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer),
contentAlignment = Alignment.Center
) {
CircularWavyProgressIndicator(
modifier = Modifier
.size(120.dp)
)
}
}
@Preview
@Composable
fun LoadingScreenPreview() {
LibrePodsTheme {
LoadingScreen()
}
}
@@ -1,101 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun MicrophoneSettingsRoute(
viewModel: AirPodsViewModel
) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
MicrophoneSettingsScreen(
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
}
)
}
}
@Composable
fun MicrophoneSettingsScreen(
selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
onMicrophoneSettingsChanged: (Int) -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.microphone_automatic),
selected = selectedMode == 0,
onClick = { onMicrophoneSettingsChanged(0) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_right),
selected = selectedMode == 1,
onClick = { onMicrophoneSettingsChanged(1) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_left),
selected = selectedMode == 2,
onClick = { onMicrophoneSettingsChanged(2) }
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}

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