Compare commits

...

76 Commits

Author SHA1 Message Date
Kavish Devar
d1b32d5a00 android: add permissions file to root module 2026-05-10 15:41:35 +05:30
Kavish Devar
044aff731f android: keep only xposed flavor
also changed Build.ID check to startsWith("CP1A")
2026-05-07 21:12:10 +05:30
Kavish Devar
216c97f9ca android: add CP1A.260505.005 to comptible build ids on Pixel 2026-05-06 17:29:23 +05:30
Kavish Devar
fd3774b513 android: bump version 2026-05-05 13:18:08 +05:30
Kavish Devar
b7336940e6 android: add convo detect broadcast 2026-05-05 13:17:31 +05:30
Kavish Devar
b2ba830a80 android: hide reconnect when app hasn't connected once 2026-05-05 13:11:50 +05:30
Kavish Devar
f08769e62f android: add optmized charge limit config 2026-05-05 13:05:54 +05:30
thisisAcidic
d1933c3b67 android: add popup toggles (#561)
* android: add toggles to disable bottom sheet and dynamic island popups

* android: translations for popup customization (de, es, fr, pt)
2026-05-05 12:48:22 +05:30
thisisAcidic
fb44f01ac0 android: allow non-premium users to disable head gestures (#564) 2026-05-03 01:41:23 +05:30
thisisAcidic
93a93cbe68 fix: sync magisk update json with current release URLs (#563) 2026-05-03 00:59:43 +05:30
Nikhil Maddirala
a4898293b8 docs: update readme root requirements (#557)
* Update readme root requirements

Clarified root requirements for LibrePods depending on device/OS and features needed.

* Revise Xposed workaround note in README

Updated warning about Xposed/LSPosed workaround for compatibility.
2026-05-01 20:06:42 +05:30
Kavish Devar
845f26192c android: make head tracking screen scrollable 2026-04-30 12:53:02 +05:30
Kavish Devar
3321bb1c43 android: bump version 2026-04-30 01:07:43 +05:30
Kavish Devar
c7a5cb2d8c android: fix crash in listening mode widget when service is null 2026-04-30 01:03:51 +05:30
Kavish Devar
7b81411417 android: fix media not resuming when using single AirPod 2026-04-30 01:00:15 +05:30
Kavish Devar
d80f2275a1 android: remove NativeBridge calls from app settings 2026-04-30 00:58:42 +05:30
Kavish Devar
795bebc6ae android: use pressandhold settings when cycling modes 2026-04-28 20:29:00 +05:30
Kavish Devar
4ef3e4d4da ci: post nightly release to #android-ci on discord 2026-04-28 18:17:04 +05:30
Kavish Devar
b88b14de15 move things around 2026-04-28 17:27:11 +05:30
Kavish Devar
7cd4dfa3e0 remove head tracking 2026-04-28 17:27:00 +05:30
Kavish Devar
30d16e9977 docs: add some docs
I made this a while back, it is very incomplete, just adding in case anyone's looking for the opcodes
2026-04-28 16:19:30 +05:30
Kavish Devar
ddcf15eefe android: clear root module build dir on each run 2026-04-28 12:21:46 +05:30
Kavish Devar
3e89d7f41b android: version bump 2026-04-28 12:13:14 +05:30
Kavish Devar
9eb6010a25 android: connect to audio when reconnecting to last connected device 2026-04-28 12:12:45 +05:30
Kavish Devar
60e865fc1f android: call connect to audio even if BLUETOOTH_PRIVILEGED/MODIFY_PHONE_STATE is not granted 2026-04-28 12:12:07 +05:30
Kavish Devar
629b7b917e android: fix crash on some devices not properly closing socket 2026-04-28 12:10:47 +05:30
Kavish Devar
d4ee741224 android: add disconnect button 2026-04-28 12:10:00 +05:30
Kavish Devar
4c8b0d720d android: fix crash in reading call control settings 2026-04-28 12:08:22 +05:30
Kavish Devar
e20b0f7fd7 android: fix adaptive audio strength slider not being flipped
why... why, apple?
2026-04-28 12:07:00 +05:30
Kavish Devar
b64ff1d09e android: catch exceptions when closing IslandWindow 2026-04-28 12:04:59 +05:30
Kavish Devar
37056c6de7 android: fix icon size in select list 2026-04-28 12:04:29 +05:30
Kavish Devar
3a636e37a4 android: increase bottom padding on all screens 2026-04-28 12:04:08 +05:30
Kavish Devar
136e3e8995 android: do not log writeRaw in ATTManager if socket unavailable 2026-04-28 12:03:51 +05:30
Kavish Devar
e39c1cfeba android: fix ControlCommand.parseFromBytes outofbounds crash 2026-04-28 12:03:26 +05:30
Kavish Devar
b06d780eee android: fix xposed state being set true onResume 2026-04-27 13:13:40 +05:30
Kavish Devar
70f420dedb android: fix text color in email bottom sheet 2026-04-27 10:17:33 +05:30
Kavish Devar
23193ceb39 android: load native hook from split apks when base fails 2026-04-27 10:08:05 +05:30
Kavish Devar
cb246d1287 android: update dialog and add app info to incompatible page 2026-04-26 17:36:17 +05:30
Kavish Devar
95cd677da9 android: fix bypass on pixels on older A16 version; also check Xposed scope for compatibility 2026-04-26 16:23:29 +05:30
Kavish Devar
0d049d93fb ci: use latest release tag for changelog 2026-04-26 16:17:02 +05:30
Kavish Devar
469d948061 android: add xposed check and email form
too many emails with absolutely no content
2026-04-26 05:05:20 +05:30
Kavish Devar
f5d92768e2 android: rename util->utils in normal flavor 2026-04-26 01:07:12 +05:30
Kavish Devar
8cb2951bc6 ci: fix typo in release bundle asset name 2026-04-26 00:46:54 +05:30
Kavish Devar
bb578dab23 ci: upload artifacts separately 2026-04-26 00:43:09 +05:30
Kavish Devar
b1b47048a3 ci: fix keystore and add manual trigger 2026-04-26 00:29:14 +05:30
Hugo Holmqvist
bf09300dfe android: fix bypass_device_check.v2 being silently ignored (#543) 2026-04-26 00:21:58 +05:30
Kavish Devar
70165232c0 ci: fix ndk 2026-04-26 00:18:03 +05:30
Kavish Devar
aabbc902cb ci: release nightly builds on all changes 2026-04-25 23:58:52 +05:30
Kavish Devar
0ee7056600 android: fix versionName in builds 2026-04-25 23:58:23 +05:30
Kavish Devar
8b24ac49e2 android: add scroll on compatibility check 2026-04-24 21:35:22 +05:30
Kavish Devar
d2dd722bc7 android: change device bypass sharedPref key 2026-04-24 20:29:37 +05:30
Kavish Devar
67fc93bde5 android: add packaging task 2026-04-24 19:50:35 +05:30
Kavish Devar
0b578d62cf android: add more compatibility information, fix FOSS billing, hide upgrade button before first AACP connect
closes #538
2026-04-24 19:50:35 +05:30
Kavish Devar
072b9b4dac android: remove radare root module 2026-04-24 19:50:35 +05:30
Kavish Devar
0af60cd8a9 android: fix ATT on A16QPR3+ 2026-04-24 19:50:35 +05:30
Kavish Devar
be29a46dab android: check for A16 on OP/Oppo devices 2026-04-24 19:50:35 +05:30
Kavish Devar
7461f7dfb7 android: remove debugging logs 2026-04-24 19:50:35 +05:30
Kavish Devar
904c00afce android: fix xposed module in release builds 2026-04-24 19:50:35 +05:30
abc0922001
6272357d84 android: update zh-rTW translation (#536) 2026-04-23 20:47:12 +05:30
Kavish Devar
6ac6700be6 android: format l2c_fcr_hook.cpp 2026-04-23 18:45:00 +05:30
Kavish Devar
113ee0a966 android: fallback to .dynsym when .gnu_debugdata fails 2026-04-23 18:45:00 +05:30
Kavish Devar
d82e4e2427 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
481d5f13cf android: fix automatically pausing when media changes without vendorid hook enabled 2026-04-23 18:45:00 +05:30
Kavish Devar
ef221af505 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
c19190f031 android: fix convo detect not restoring volume when in Transparency mode 2026-04-23 18:45:00 +05:30
Kavish Devar
d0b8574c68 android: hide disconnect when not wearing config on play builds 2026-04-23 18:45:00 +05:30
Kavish Devar
294d733e71 android: add 'required xposed' text to vendorid config toggle 2026-04-23 18:45:00 +05:30
Kavish Devar
f6d7e97796 android: show price in buy button 2026-04-23 18:45:00 +05:30
Kavish Devar
ae174bc9ea android: add confirmation step for unsupported devices 2026-04-23 18:45:00 +05:30
Kavish Devar
1804e80cba docs: fix typo 2026-04-23 05:43:43 +05:30
Kavish Devar
0b8bd5a5b8 docs: fix issuetracker link in README 2026-04-23 05:42:58 +05:30
Kavish Devar
d1d48562d7 docs: clarify root requirements 2026-04-23 05:41:56 +05:30
Kavish Devar
c84e64e656 android: remove unsupported device message 2026-04-23 01:27:55 +05:30
Kavish Devar
51739514fa android: fix normal builds 2026-04-23 01:22:49 +05:30
Tomáš Ulrych
8f9a04390a android: cap popup width on tablets and large screens (#521)
On tablets in landscape mode, the AirPods connection popup covers the
entire screen because width is set to MATCH_PARENT. This caps the popup
width to 400dp on screens wider than 600dp and centers it horizontally.

Also removes a duplicate gravity assignment in init that was overwriting
the CENTER_HORIZONTAL flag.

Fixes #425

Co-authored-by: TomasULR <creator_d@nvias.org>
2026-04-23 01:11:33 +05:30
Kavish Devar
2363b80548 android: stability fixes and xposed api v101 (#531) 2026-04-23 00:48:33 +05:30
195 changed files with 14410 additions and 10493 deletions

View File

@@ -1,100 +1,194 @@
name: Build APK and root module (and create nightly release) name: Android CI
on: on:
push: push:
branches: branches:
- '*' - '*'
paths:
- 'android/**'
- 'root-module-manual/**'
pull_request:
paths: paths:
- 'android/**' - 'android/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: branch:
description: 'Create a nightly release' description: Branch to build
required: true required: true
type: boolean default: main
default: false
custom_notes:
description: 'Custom updates to add to What''s Changed section'
required: false
type: string
workflow_call: workflow_call:
jobs: jobs:
build-debug-apk: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
app_version: ${{ steps.version.outputs.app_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: zulu
java-version: 21 java-version: 21
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
- name: Build debug APK - name: Decode keystore
run: ./gradlew assembleDebug 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
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
run: ./gradlew packageReleaseArtifacts
working-directory: android working-directory: android
- name: Upload artifact - name: Get app version
uses: actions/upload-artifact@v4 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
with: with:
name: Debug APK name: apk-release
path: android/app/build/outputs/apk/**/*.apk path: release/*release.apk
nightly-release:
- uses: actions/upload-artifact@v4
with:
name: apk-debug
path: release/*debug.apk
- uses: actions/upload-artifact@v4
with:
name: root-module-release
path: release/*release.zip
- uses: actions/upload-artifact@v4
with:
name: root-module-debug
path: release/*debug.zip
- uses: actions/upload-artifact@v4
with:
name: release-bundle
path: release/*.aab
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true' needs: build
needs: build-debug-apk
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Export APK_NAME for later use with:
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV name: apk-release
- name: Rename .apk file path: artifacts/apk-release
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 - uses: actions/download-artifact@v4
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then with:
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}" name: apk-debug
path: artifacts/apk-debug
# Check if custom notes already have bullet points or GitHub-style formatting - uses: actions/download-artifact@v4
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then with:
# Already formatted, use as is name: root-module-release
echo "$CUSTOM_NOTES" > "$NOTES_FILE" path: artifacts/root-module-release
else
# Add bullet point formatting
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
fi
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT - uses: actions/download-artifact@v4
- name: Zip root-module directory with:
run: sh ./build-magisk-module.sh name: root-module-debug
- name: Delete release if exist then create release path: artifacts/root-module-debug
- id: prev
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag TAG=$(gh release list \
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes --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"
}
]
}
]
}'

7
.gitignore vendored
View File

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

View File

@@ -1,164 +0,0 @@
# 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).

View File

@@ -76,10 +76,19 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Root Requirement ### Root Requirement
If you are using ColorOS/OxygenOS 16, Android 16 QPR3, Android 17 Beta 3 or higher, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. LibrePods **may** require root depending on your device/OS and what features you want access to:
For everyone else: - 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.
**You must have a rooted device with Xposed to use LibrePods on Android.** - On **ColorOS/OxygenOS 16** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features (except those requiring the VendorID hook mentioned above).
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). Please do not comment on the issue thread. The issue has already been resolved and should be available in **Android 17** for all devices.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
### A few notes ### A few notes
@@ -140,7 +149,7 @@ Want to try the latest features before they're officially released? You can grab
4. Extract the zip and install the `.apk` on your device 4. Extract the zip and install the `.apk` on your device
> [!NOTE] > [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update — you may need to uninstall the stable version first. > You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
### Linux (Rust) ### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml) 1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)

View File

@@ -1,42 +1,84 @@
import java.util.Properties
val appVersionName = "0.2.9"
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries) alias(libs.plugins.aboutLibraries)
// alias(libs.plugins.hilt)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
val props = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
android { android {
signingConfigs {
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
keyAlias = props["RELEASE_KEY_ALIAS"] as String
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
}
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 targetSdk = 37
targetSdk = 36 versionCode = 52
versionCode = 9 versionName = appVersionName
versionName = "0.2.0"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
"proguard-rules.pro"
) )
externalNativeBuild {
cmake {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
defaultConfig {
minSdk = 33
}
}
debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
buildConfig = true
} }
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
@@ -49,18 +91,22 @@ android {
} }
sourceSets { sourceSets {
getByName("main") { getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple") res.directories += "src/main/res-apple"
}
} }
} }
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
}
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.hiddenapibypass)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
@@ -72,15 +118,19 @@ dependencies {
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation) implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries) implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) implementation(libs.backdrop)
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) // implementation(libs.hilt)
compileOnly(files("libs/libxposed-api-100.aar")) // implementation(libs.hilt.compiler)
debugImplementation(files("libs/backdrop-debug.aar")) compileOnly(libs.libxposed.api)
releaseImplementation(files("libs/backdrop-release.aar")) implementation(libs.libxposed.service)
implementation(libs.play.review)
implementation(libs.play.review.ktx)
} }
aboutLibraries { aboutLibraries {
@@ -90,3 +140,79 @@ aboutLibraries {
outputFile = file("src/main/res/raw/aboutlibraries.json") outputFile = file("src/main/res/raw/aboutlibraries.json")
} }
} }
val rootModuleDir = rootProject.file("../root-module-manual")
val releaseDir = rootProject.file("../release")
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
fun registerRootModuleZipTask(
name: String,
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
from(rootModuleDir)
duplicatesStrategy = DuplicatesStrategy.WARN
from(layout.buildDirectory.file(apkPath)) {
into("system/priv-app/LibrePods")
rename { "LibrePods.apk" }
}
delete(layout.buildDirectory.dir("outputs/rootModuleZips"))
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
}
val zipRelease = registerRootModuleZipTask(
"zipReleaseModule",
"foss",
"release"
)
val zipDebug = registerRootModuleZipTask(
"zipDebugModule",
"foss",
"debug"
)
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn(
zipRelease,
zipDebug,
"bundlePlayRelease"
)
into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
}
from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
}
from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
include("*.aab")
}
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
include("*.zip")
}
}
tasks.register("packageReleaseArtifacts") {
dependsOn(collect)
}

Binary file not shown.

View File

@@ -19,3 +19,5 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }

View File

@@ -17,6 +17,15 @@
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_PRIVILEGED" android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
@@ -26,15 +35,15 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" /> <!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
tools:ignore="ScopedStorage" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
android:maxSdkVersion="30" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" <uses-permission android:name="com.android.vending.BILLING" />
android:maxSdkVersion="30" />
<application <application
android:name=".LibrePodsApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
@@ -45,10 +54,9 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LibrePods" android:theme="@style/Theme.LibrePods"
android:description="@string/app_description" android:description="@string/app_description"
tools:ignore="UnusedAttribute" tools:ignore="UnusedAttribute" >
tools:targetApi="31">
<receiver <receiver
android:name=".widgets.NoiseControlWidget" android:name=".presentation.widgets.NoiseControlWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -60,7 +68,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.BatteryWidget" android:name=".presentation.widgets.BatteryWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -114,17 +122,17 @@
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service <!-- <service-->
android:name=".services.AppListenerService" <!-- android:name=".services.AppListenerService"-->
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" <!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
android:exported="true"> <!-- android:exported="true">-->
<intent-filter> <!-- <intent-filter>-->
<action android:name="android.accessibilityservice.AccessibilityService" /> <!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
</intent-filter> <!-- </intent-filter>-->
<meta-data <!-- <meta-data-->
android:name="android.accessibilityservice" <!-- android:name="android.accessibilityservice"-->
android:resource="@xml/app_listener_service_config" /> <!-- android:resource="@xml/app_listener_service_config" />-->
</service> <!-- </service>-->
<receiver <receiver
android:name=".receivers.BootReceiver" android:name=".receivers.BootReceiver"
android:enabled="true" android:enabled="true"

View File

@@ -3,10 +3,55 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook") project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
add_library(${CMAKE_PROJECT_NAME} SHARED add_library(bluetooth_socket SHARED
l2c_fcr_hook.cpp bluetooth_socket.cpp
l2c_fcr_hook.h) )
target_link_libraries(${CMAKE_PROJECT_NAME} target_compile_options(bluetooth_socket PRIVATE
-O2
-fvisibility=hidden
)
target_link_options(bluetooth_socket PRIVATE
-Wl,--strip-all
-Wl,--gc-sections
)
target_link_libraries(bluetooth_socket
android android
log) log
)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
xz/xz_crc32.c
xz/xz_crc64.c
xz/xz_sha256.c
xz/xz_dec_stream.c
xz/xz_dec_lzma2.c
xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_link_libraries(l2c_fcr_hook
android
log
)

View File

@@ -0,0 +1,68 @@
#include <jni.h>
#include <pthread.h>
#include <array>
#include <string>
static JavaVM* gVm = nullptr;
template<size_t N>
constexpr auto encryptString(const char (&str)[N], char key) {
std::array<char, N> encrypted{};
for (size_t i = 0; i < N; i++) {
encrypted[i] = str[i] ^ key;
}
return encrypted;
}
template<size_t N>
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
std::string result(N - 1, '\0');
for (size_t i = 0; i < N - 1; i++) {
result[i] = encrypted[i] ^ key;
}
return result;
}
#define ENC(str) encryptString(str, 0x47)
#define DEC(arr) decryptString(arr, 0x47).c_str()
__attribute__((visibility("hidden")))
static JavaVM* getVm() { return gVm; }
__attribute__((visibility("default")))
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gVm = vm;
auto fn = [](void*) -> void* {
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
constexpr auto c2 = ENC("getRuntime");
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
constexpr auto c4 = ENC("setHiddenApiExemptions");
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
jclass vmRuntime = env->FindClass(DEC(c1));
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
jobjectArray prefixes = env->NewObjectArray(
2, env->FindClass(DEC(c6)), nullptr);
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
env->CallVoidMethod(runtime, setExemptions, prefixes);
getVm()->DetachCurrentThread();
return nullptr;
};
pthread_t t;
pthread_create(&t, nullptr, fn, nullptr);
pthread_join(t, nullptr);
return JNI_VERSION_1_6;
}

View File

@@ -16,476 +16,420 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include <cstdint>
#include <cstring>
#include <dlfcn.h>
#include <android/log.h> #include <android/log.h>
#include <fstream> #include <cstring>
#include <string> #include <string>
#include <sys/system_properties.h> #include <vector>
#include "l2c_fcr_hook.h" #include <fcntl.h>
#include <cerrno> #include <unistd.h>
#include <cstdlib> #include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#define LOG_TAG "AirPodsHook" #include "l2c_fcr_hook.h"
extern "C" {
#include "xz.h"
}
#define LOG_TAG "LibrePodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr; static HookFunType hook_func = nullptr;
#define L2CEVT_L2CAP_CONFIG_REQ 4
#define L2CEVT_L2CAP_CONFIG_RSP 15
struct t_l2c_lcb; static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
typedef struct _BT_HDR {
uint16_t event;
uint16_t len;
uint16_t offset;
uint16_t layer_specific;
uint8_t data[];
} BT_HDR;
typedef struct { static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
uint8_t mode;
uint8_t tx_win_sz;
uint8_t max_transmit;
uint16_t rtrans_tout;
uint16_t mon_tout;
uint16_t mps;
} tL2CAP_FCR;
// Flow spec structure static std::atomic<bool> enableSdpHook(false);
typedef struct {
uint8_t qos_present;
uint8_t flow_direction;
uint8_t service_type;
uint32_t token_rate;
uint32_t token_bucket_size;
uint32_t peak_bandwidth;
uint32_t latency;
uint32_t delay_variation;
} FLOW_SPEC;
// Configuration info structure
typedef struct {
uint16_t result;
uint16_t mtu_present;
uint16_t mtu;
uint16_t flush_to_present;
uint16_t flush_to;
uint16_t qos_present;
FLOW_SPEC qos;
uint16_t fcr_present;
tL2CAP_FCR fcr;
uint16_t fcs_present;
uint16_t fcs;
uint16_t ext_flow_spec_present;
FLOW_SPEC ext_flow_spec;
} tL2CAP_CFG_INFO;
// Basic L2CAP link control block
typedef struct {
bool wait_ack;
// Other FCR fields - not needed for our specific hook
} tL2C_FCRB;
// Forward declarations for needed types
struct t_l2c_rcb;
struct t_l2c_lcb;
typedef struct t_l2c_ccb {
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
uint16_t local_cid; // Local CID
uint16_t remote_cid; // Remote CID
uint16_t p_lcb_next; // For linking CCBs to an LCB
uint8_t ccb_priority; // Channel priority
uint16_t tx_mps; // MPS for outgoing messages
uint16_t max_rx_mtu; // Max MTU we will receive
// State variables
bool in_use; // True when channel active
uint8_t chnl_state; // Channel state
uint8_t local_id; // Transaction ID for local trans
uint8_t remote_id; // Transaction ID for remote
uint8_t timer_entry; // Timer entry
uint8_t is_flushable; // True if flushable
// Configuration variables
uint16_t our_cfg_bits; // Bitmap of local config bits
uint16_t peer_cfg_bits; // Bitmap of peer config bits
uint16_t config_done; // Configuration bitmask
uint16_t remote_config_rsp_result; // Remote config response result
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
// Additional control fields
uint8_t remote_credit_count; // Credits sent to peer
tL2C_FCRB fcrb; // FCR info
bool ecoc; // Enhanced Credit-based mode
} tL2C_CCB;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
// Add original pointer for BTA_DmSetLocalDiRecord
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) { uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked, returning true."); LOGI("fake_l2c_fcr_chk_chan_modes called");
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);
return 1; return 1;
} }
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
p_ccb->our_cfg.fcr.mode = 0x00;
LOGI("Set FCR mode to Basic Mode in outgoing config request");
}
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
// Call the original function first to handle the specific code path where the FCR mode is checked
original_l2c_csm_config(p_ccb, event, p_data);
// Check if this happens during CONFIG_RSP event handling
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
}
}
// Replacement function that does nothing
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
// Just return without doing anything
return;
}
// New loader for SDP hook offset (persist.librepods.sdp_offset)
uintptr_t loadSdpOffset() {
const char* property_name = "persist.librepods.sdp_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read sdp offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed sdp offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse sdp offset from property value: %s", value);
}
LOGI("No sdp offset property present - skipping SDP hook");
return 0;
}
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) { tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
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");
if (p_device_info) { if (p_device_info) {
p_device_info->vendor = 0x004C; p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001; p_device_info->vendor_id_source = 0x0001;
} }
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
if (original_BTA_DmSetLocalDiRecord) { LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); 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;
} }
LOGE("Original BTA_DmSetLocalDiRecord not available"); static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
return BTA_FAILURE;
LOGI("decompressXZ called with input_size: %zu", input_size);
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");
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) { struct xz_buf buf{};
const char* property_name = "persist.librepods.hook_offset"; buf.in = input;
char value[PROP_VALUE_MAX] = {0}; buf.in_pos = 0;
buf.in_size = input_size;
int len = __system_property_get(property_name, value); output.resize(input_size * 8);
if (len > 0) {
LOGI("Read hook offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value; buf.out = output.data();
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { buf.out_pos = 0;
parse_start = value + 2; buf.out_size = output.size();
}
errno = 0; LOGI("decompressXZ: entering decompression loop");
offset = strtoul(parse_start, &endptr, 16); 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);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) { LOGI("decompressXZ: xz_dec_run returned %d", ret);
LOGI("Parsed offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse offset from property value: %s", value); if (ret == XZ_STREAM_END)
}
LOGI("Using hardcoded fallback offset");
return 0x00a55e30;
}
uintptr_t loadL2cuProcessCfgReqOffset() {
const char* property_name = "persist.librepods.cfg_req_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t loadL2cCsmConfigOffset() {
const char* property_name = "persist.librepods.csm_config_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2c_csm_config offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t loadL2cuSendPeerInfoReqOffset() {
const char* property_name = "persist.librepods.peer_info_req_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t getModuleBase(const char *module_name) {
FILE *fp;
char line[1024];
uintptr_t base_addr = 0;
fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("Failed to open /proc/self/maps");
return 0;
}
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, module_name)) {
char *start_addr_str = line;
char *end_addr_str = strchr(line, '-');
if (end_addr_str) {
*end_addr_str = '\0';
base_addr = strtoull(start_addr_str, nullptr, 16);
break; 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();
}
}
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;
}
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, '/');
if (path) {
out = path;
out.erase(out.find('\n'));
LOGI("getLibraryPath: path found: %s", out.c_str());
fclose(fp);
return true;
} }
} }
} }
fclose(fp); fclose(fp);
return base_addr; LOGI("getLibraryPath: failed to find path for %s", name);
return false;
} }
bool findAndHookFunction(const char *library_name) { 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;
}
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) {
LOGI("findSymbolOffsetDynsym called with %s", symbol_substring);
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
elf.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
elf.data() + shdr[eh->e_shstrndx].sh_offset);
const Elf64_Shdr *dynsym = nullptr;
const Elf64_Shdr *dynstr = nullptr;
for (int i = 0; i < eh->e_shnum; ++i) {
const char *secname = shstr + shdr[i].sh_name;
if (!strcmp(secname, ".dynsym"))
dynsym = &shdr[i];
if (!strcmp(secname, ".dynstr"))
dynstr = &shdr[i];
}
if (!dynsym || !dynstr) {
LOGE("findSymbolOffsetDynsym: dynsym or dynstr not found");
return 0;
}
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
elf.data() + dynsym->sh_offset);
const char *strings = reinterpret_cast<const char *>(
elf.data() + dynstr->sh_offset);
size_t count = dynsym->sh_size / sizeof(Elf64_Sym);
LOGI("findSymbolOffsetDynsym: scanning %zu symbols", count);
for (size_t i = 0; i < count; ++i) {
const char *name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffsetDynsym: matched %s @ 0x%lx", name,
(unsigned long) symbols[i].st_value);
return symbols[i].st_value;
}
}
LOGI("findSymbolOffsetDynsym: no match for %s", symbol_substring);
return 0;
}
static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *symbol_substring) {
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;
if (!strcmp(secname, ".symtab"))
symtab = &shdr[i];
if (!strcmp(secname, ".strtab"))
strtab = &shdr[i];
}
if (!symtab || !strtab) {
LOGE("findSymbolOffset: symtab or strtab not found");
return 0;
}
LOGI("findSymbolOffset: found symtab and strtab");
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
elf.data() + symtab->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;
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);
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);
if (!hook_func) { if (!hook_func) {
LOGE("Hook function not initialized"); LOGE("hook_func not initialized");
return false; return false;
} }
uintptr_t base_addr = getModuleBase(library_name); std::string path;
if (!base_addr) { if (!getLibraryPath(libname, path)) {
LOGE("Failed to get base address of %s", library_name); 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; return false;
} }
// Load all offsets from system properties - no hardcoding struct stat st{};
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr); if (fstat(fd, &st) != 0) {
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset(); LOGE("hookLibrary: fstat failed");
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset(); close(fd);
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
uintptr_t sdp_offset = loadSdpOffset();
bool success = false;
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
if (l2c_fcr_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
l2c_fcr_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
if (result != 0) {
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
return false; return false;
} }
LOGI("Successfully hooked l2c_fcr_chk_chan_modes"); LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
success = true;
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 *>(
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;
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed;
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
} else { } else {
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed"); LOGE("debugdata decompress failed");
}
break;
}
}
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; return false;
} }
// Hook l2cu_process_our_cfg_req if offset is available if (chk_offset) {
if (l2cu_process_our_cfg_req_offset > 0) { void *target = reinterpret_cast<void *>(base + chk_offset);
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset); hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p", (void **) &original_l2c_fcr_chk_chan_modes);
l2cu_process_our_cfg_req_offset, (void*)base_addr, target); LOGI("hooked chk");
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
if (result != 0) {
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_process_our_cfg_req");
}
} else {
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
} }
// Hook l2c_csm_config if offset is available if (sdp_offset) {
if (l2c_csm_config_offset > 0) { void *target = reinterpret_cast<void *>(base + sdp_offset);
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset); hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p", (void **) &original_BTA_DmSetLocalDiRecord);
l2c_csm_config_offset, (void*)base_addr, target); LOGI("hooked sdp");
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
if (result != 0) {
LOGE("Failed to hook l2c_csm_config, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2c_csm_config");
}
} else {
LOGI("Skipping l2c_csm_config hook as offset is not available");
} }
// Hook l2cu_send_peer_info_req if offset is available return chk_offset || sdp_offset;
if (l2cu_send_peer_info_req_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
if (result != 0) {
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_send_peer_info_req");
}
} else {
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
} }
if (sdp_offset > 0) { static void on_library_loaded(const char *name, void *) {
void* target = reinterpret_cast<void*>(base_addr + sdp_offset); LOGI("on_library_loaded called with name: %s", name);
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
sdp_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
if (result != 0) {
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
} else {
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
}
} else {
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
}
return success;
}
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
if (strstr(name, "libbluetooth_jni.so")) { if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Detected Bluetooth JNI library: %s", name); LOGI("Bluetooth JNI loaded");
hookLibrary("libbluetooth_jni.so");
bool hooked = findAndHookFunction("libbluetooth_jni.so");
if (!hooked) {
LOGE("Failed to hook Bluetooth JNI library function");
} }
} else if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Detected Bluetooth QTI library: %s", name);
bool hooked = findAndHookFunction("libbluetooth_qti.so"); if (strstr(name, "libbluetooth_qti.so")) {
if (!hooked) { LOGI("Bluetooth QTI loaded");
LOGE("Failed to hook Bluetooth QTI library function"); hookLibrary("libbluetooth_qti.so");
}
} }
} }
extern "C" [[gnu::visibility("default")]] [[gnu::used]] extern "C" [[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("L2C FCR Hook module initialized"); LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func;
hook_func = entries->hook_func; LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded; 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);
}

View File

@@ -1,44 +1,35 @@
#pragma once /*
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/>.
*/
#pragma once
#include <cstdint> #include <cstdint>
#include <vector>
typedef int (*HookFunType)(void *func, void *replace, void **backup); typedef int (*HookFunType)(void *func, void *replace, void **backup);
typedef int (*UnhookFunType)(void *func);
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle); typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
typedef struct { typedef struct {
uint32_t version; uint32_t version;
HookFunType hook_func; void* hook_func;
UnhookFunType unhook_func; void* unhook_func;
} NativeAPIEntries; } NativeAPIEntries;
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries); typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
typedef struct t_l2c_ccb tL2C_CCB;
typedef struct t_l2c_lcb tL2C_LCB;
uintptr_t loadHookOffset(const char* package_name);
uintptr_t getModuleBase(const char *module_name);
uintptr_t loadL2cuProcessCfgReqOffset();
uintptr_t loadL2cCsmConfigOffset();
uintptr_t loadL2cuSendPeerInfoReqOffset();
bool findAndHookFunction(const char *library_path);
#define SDP_MAX_ATTR_LEN 400
typedef struct t_sdp_di_record {
uint16_t vendor;
uint16_t vendor_id_source;
uint16_t product;
uint16_t version;
bool primary_record;
char client_executable_url[SDP_MAX_ATTR_LEN];
char service_description[SDP_MAX_ATTR_LEN];
char documentation_url[SDP_MAX_ATTR_LEN];
} tSDP_DI_RECORD;
typedef enum : uint8_t { typedef enum : uint8_t {
BTA_SUCCESS = 0, /* Successful operation. */ BTA_SUCCESS = 0, /* Successful operation. */
@@ -48,3 +39,14 @@ typedef enum : uint8_t {
BTA_NO_RESOURCES = 4, BTA_NO_RESOURCES = 4,
BTA_WRONG_MODE = 5, BTA_WRONG_MODE = 5,
} tBTA_STATUS; } tBTA_STATUS;
typedef struct t_sdp_di_record {
uint16_t vendor;
uint16_t vendor_id_source;
uint16_t product;
uint16_t version;
bool primary_record;
char client_executable_url[400];
char service_description[400];
char documentation_url[400];
} tSDP_DI_RECORD;

View File

@@ -0,0 +1,448 @@
/* SPDX-License-Identifier: 0BSD */
/*
* XZ decompressor
*
* Authors: Lasse Collin <lasse.collin@tukaani.org>
* Igor Pavlov <https://7-zip.org/>
*/
#ifndef XZ_H
#define XZ_H
#ifdef __KERNEL__
# include <linux/stddef.h>
# include <linux/types.h>
#else
# include <stddef.h>
# include <stdint.h>
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* "#define XZ_EXTERN static" can be used to make extern functions static. */
#ifndef XZ_EXTERN
# define XZ_EXTERN extern
#endif
/**
* enum xz_mode - Operation mode
*
* @XZ_SINGLE: Single-call mode. This uses less RAM than
* multi-call modes, because the LZMA2
* dictionary doesn't need to be allocated as
* part of the decoder state. All required data
* structures are allocated at initialization,
* so xz_dec_run() cannot return XZ_MEM_ERROR.
* @XZ_PREALLOC: Multi-call mode with preallocated LZMA2
* dictionary buffer. All data structures are
* allocated at initialization, so xz_dec_run()
* cannot return XZ_MEM_ERROR.
* @XZ_DYNALLOC: Multi-call mode. The LZMA2 dictionary is
* allocated once the required size has been
* parsed from the stream headers. If the
* allocation fails, xz_dec_run() will return
* XZ_MEM_ERROR.
*
* It is possible to enable support only for a subset of the above
* modes at compile time by defining XZ_DEC_SINGLE, XZ_DEC_PREALLOC,
* or XZ_DEC_DYNALLOC. The xz_dec kernel module is always compiled
* with support for all operation modes, but the preboot code may
* be built with fewer features to minimize code size.
*/
enum xz_mode {
XZ_SINGLE,
XZ_PREALLOC,
XZ_DYNALLOC
};
/**
* enum xz_ret - Return codes
* @XZ_OK: Everything is OK so far. More input or more
* output space is required to continue. This
* return code is possible only in multi-call mode
* (XZ_PREALLOC or XZ_DYNALLOC).
* @XZ_STREAM_END: Operation finished successfully.
* @XZ_UNSUPPORTED_CHECK: Integrity check type is not supported. Decoding
* is still possible in multi-call mode by simply
* calling xz_dec_run() again.
* Note that this return value is used only if
* XZ_DEC_ANY_CHECK was defined at build time,
* which is not used in the kernel. Unsupported
* check types return XZ_OPTIONS_ERROR if
* XZ_DEC_ANY_CHECK was not defined at build time.
* @XZ_MEM_ERROR: Allocating memory failed. This return code is
* possible only if the decoder was initialized
* with XZ_DYNALLOC. The amount of memory that was
* tried to be allocated was no more than the
* dict_max argument given to xz_dec_init().
* @XZ_MEMLIMIT_ERROR: A bigger LZMA2 dictionary would be needed than
* allowed by the dict_max argument given to
* xz_dec_init(). This return value is possible
* only in multi-call mode (XZ_PREALLOC or
* XZ_DYNALLOC); the single-call mode (XZ_SINGLE)
* ignores the dict_max argument.
* @XZ_FORMAT_ERROR: File format was not recognized (wrong magic
* bytes).
* @XZ_OPTIONS_ERROR: This implementation doesn't support the requested
* compression options. In the decoder this means
* that the header CRC32 matches, but the header
* itself specifies something that we don't support.
* @XZ_DATA_ERROR: Compressed data is corrupt.
* @XZ_BUF_ERROR: Cannot make any progress. Details are slightly
* different between multi-call and single-call
* mode; more information below.
*
* In multi-call mode, XZ_BUF_ERROR is returned when two consecutive calls
* to XZ code cannot consume any input and cannot produce any new output.
* This happens when there is no new input available, or the output buffer
* is full while at least one output byte is still pending. Assuming your
* code is not buggy, you can get this error only when decoding a compressed
* stream that is truncated or otherwise corrupt.
*
* In single-call mode, XZ_BUF_ERROR is returned only when the output buffer
* is too small or the compressed input is corrupt in a way that makes the
* decoder produce more output than the caller expected. When it is
* (relatively) clear that the compressed input is truncated, XZ_DATA_ERROR
* is used instead of XZ_BUF_ERROR.
*/
enum xz_ret {
XZ_OK,
XZ_STREAM_END,
XZ_UNSUPPORTED_CHECK,
XZ_MEM_ERROR,
XZ_MEMLIMIT_ERROR,
XZ_FORMAT_ERROR,
XZ_OPTIONS_ERROR,
XZ_DATA_ERROR,
XZ_BUF_ERROR
};
/**
* struct xz_buf - Passing input and output buffers to XZ code
* @in: Beginning of the input buffer. This may be NULL if and only
* if in_pos is equal to in_size.
* @in_pos: Current position in the input buffer. This must not exceed
* in_size.
* @in_size: Size of the input buffer
* @out: Beginning of the output buffer. This may be NULL if and only
* if out_pos is equal to out_size.
* @out_pos: Current position in the output buffer. This must not exceed
* out_size.
* @out_size: Size of the output buffer
*
* Only the contents of the output buffer from out[out_pos] onward, and
* the variables in_pos and out_pos are modified by the XZ code.
*/
struct xz_buf {
const uint8_t *in;
size_t in_pos;
size_t in_size;
uint8_t *out;
size_t out_pos;
size_t out_size;
};
/*
* struct xz_dec - Opaque type to hold the XZ decoder state
*/
struct xz_dec;
/**
* xz_dec_init() - Allocate and initialize a XZ decoder state
* @mode: Operation mode
* @dict_max: Maximum size of the LZMA2 dictionary (history buffer) for
* multi-call decoding. This is ignored in single-call mode
* (mode == XZ_SINGLE). LZMA2 dictionary is always 2^n bytes
* or 2^n + 2^(n-1) bytes (the latter sizes are less common
* in practice), so other values for dict_max don't make sense.
* In the kernel, dictionary sizes of 64 KiB, 128 KiB, 256 KiB,
* 512 KiB, and 1 MiB are probably the only reasonable values,
* except for kernel and initramfs images where a bigger
* dictionary can be fine and useful.
*
* Single-call mode (XZ_SINGLE): xz_dec_run() decodes the whole stream at
* once. The caller must provide enough output space or the decoding will
* fail. The output space is used as the dictionary buffer, which is why
* there is no need to allocate the dictionary as part of the decoder's
* internal state.
*
* Because the output buffer is used as the workspace, streams encoded using
* a big dictionary are not a problem in single-call mode. It is enough that
* the output buffer is big enough to hold the actual uncompressed data; it
* can be smaller than the dictionary size stored in the stream headers.
*
* Multi-call mode with preallocated dictionary (XZ_PREALLOC): dict_max bytes
* of memory is preallocated for the LZMA2 dictionary. This way there is no
* risk that xz_dec_run() could run out of memory, since xz_dec_run() will
* never allocate any memory. Instead, if the preallocated dictionary is too
* small for decoding the given input stream, xz_dec_run() will return
* XZ_MEMLIMIT_ERROR. Thus, it is important to know what kind of data will be
* decoded to avoid allocating excessive amount of memory for the dictionary.
*
* Multi-call mode with dynamically allocated dictionary (XZ_DYNALLOC):
* dict_max specifies the maximum allowed dictionary size that xz_dec_run()
* may allocate once it has parsed the dictionary size from the stream
* headers. This way excessive allocations can be avoided while still
* limiting the maximum memory usage to a sane value to prevent running the
* system out of memory when decompressing streams from untrusted sources.
*
* On success, xz_dec_init() returns a pointer to struct xz_dec, which is
* ready to be used with xz_dec_run(). If memory allocation fails,
* xz_dec_init() returns NULL.
*/
XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max);
/**
* xz_dec_run() - Run the XZ decoder for a single XZ stream
* @s: Decoder state allocated using xz_dec_init()
* @b: Input and output buffers
*
* The possible return values depend on build options and operation mode.
* See enum xz_ret for details.
*
* Note that if an error occurs in single-call mode (return value is not
* XZ_STREAM_END), b->in_pos and b->out_pos are not modified and the
* contents of the output buffer from b->out[b->out_pos] onward are
* undefined. This is true even after XZ_BUF_ERROR, because with some filter
* chains, there may be a second pass over the output buffer, and this pass
* cannot be properly done if the output buffer is truncated. Thus, you
* cannot give the single-call decoder a too small buffer and then expect to
* get that amount valid data from the beginning of the stream. You must use
* the multi-call decoder if you don't want to uncompress the whole stream.
*
* Use xz_dec_run() when XZ data is stored inside some other file format.
* The decoding will stop after one XZ stream has been decompressed. To
* decompress regular .xz files which might have multiple concatenated
* streams, use xz_dec_catrun() instead.
*/
XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b);
/**
* xz_dec_catrun() - Run the XZ decoder with support for concatenated streams
* @s: Decoder state allocated using xz_dec_init()
* @b: Input and output buffers
* @finish: This is an int instead of bool to avoid requiring stdbool.h.
* As long as more input might be coming, finish must be false.
* When the caller knows that it has provided all the input to
* the decoder (some possibly still in b->in), it must set finish
* to true. Only when finish is true can this function return
* XZ_STREAM_END to indicate successful decompression of the
* file. In single-call mode (XZ_SINGLE) finish is assumed to
* always be true; the caller-provided value is ignored.
*
* This is like xz_dec_run() except that this makes it easy to decode .xz
* files with multiple streams (multiple .xz files concatenated as is).
* The rarely-used Stream Padding feature is supported too, that is, there
* can be null bytes after or between the streams. The number of null bytes
* must be a multiple of four.
*
* When finish is false and b->in_pos == b->in_size, it is possible that
* XZ_BUF_ERROR isn't returned even when no progress is possible (XZ_OK is
* returned instead). This shouldn't matter because in this situation a
* reasonable caller will attempt to provide more input or set finish to
* true for the next xz_dec_catrun() call anyway.
*
* For any struct xz_dec that has been initialized for multi-call mode:
* Once decoding has been started with xz_dec_run() or xz_dec_catrun(),
* the same function must be used until xz_dec_reset() or xz_dec_end().
* Switching between the two decoding functions without resetting results
* in undefined behavior.
*
* xz_dec_catrun() is only available if XZ_DEC_CONCATENATED was defined
* at compile time.
*/
XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b,
int finish);
/**
* xz_dec_reset() - Reset an already allocated decoder state
* @s: Decoder state allocated using xz_dec_init()
*
* This function can be used to reset the multi-call decoder state without
* freeing and reallocating memory with xz_dec_end() and xz_dec_init().
*
* In single-call mode, xz_dec_reset() is always called in the beginning of
* xz_dec_run(). Thus, explicit call to xz_dec_reset() is useful only in
* multi-call mode.
*/
XZ_EXTERN void xz_dec_reset(struct xz_dec *s);
/**
* xz_dec_end() - Free the memory allocated for the decoder state
* @s: Decoder state allocated using xz_dec_init(). If s is NULL,
* this function does nothing.
*/
XZ_EXTERN void xz_dec_end(struct xz_dec *s);
/**
* DOC: MicroLZMA decompressor
*
* This MicroLZMA header format was created for use in EROFS but may be used
* by others too. **In most cases one needs the XZ APIs above instead.**
*
* The compressed format supported by this decoder is a raw LZMA stream
* whose first byte (always 0x00) has been replaced with bitwise-negation
* of the LZMA properties (lc/lp/pb) byte. For example, if lc/lp/pb is
* 3/0/2, the first byte is 0xA2. This way the first byte can never be 0x00.
* Just like with LZMA2, lc + lp <= 4 must be true. The LZMA end-of-stream
* marker must not be used. The unused values are reserved for future use.
*/
/*
* struct xz_dec_microlzma - Opaque type to hold the MicroLZMA decoder state
*/
struct xz_dec_microlzma;
/**
* xz_dec_microlzma_alloc() - Allocate memory for the MicroLZMA decoder
* @mode: XZ_SINGLE or XZ_PREALLOC
* @dict_size: LZMA dictionary size. This must be at least 4 KiB and
* at most 3 GiB.
*
* In contrast to xz_dec_init(), this function only allocates the memory
* and remembers the dictionary size. xz_dec_microlzma_reset() must be used
* before calling xz_dec_microlzma_run().
*
* The amount of allocated memory is a little less than 30 KiB with XZ_SINGLE.
* With XZ_PREALLOC also a dictionary buffer of dict_size bytes is allocated.
*
* On success, xz_dec_microlzma_alloc() returns a pointer to
* struct xz_dec_microlzma. If memory allocation fails or
* dict_size is invalid, NULL is returned.
*/
XZ_EXTERN struct xz_dec_microlzma *xz_dec_microlzma_alloc(enum xz_mode mode,
uint32_t dict_size);
/**
* xz_dec_microlzma_reset() - Reset the MicroLZMA decoder state
* @s: Decoder state allocated using xz_dec_microlzma_alloc()
* @comp_size: Compressed size of the input stream
* @uncomp_size: Uncompressed size of the input stream. A value smaller
* than the real uncompressed size of the input stream can
* be specified if uncomp_size_is_exact is set to false.
* uncomp_size can never be set to a value larger than the
* expected real uncompressed size because it would eventually
* result in XZ_DATA_ERROR.
* @uncomp_size_is_exact: This is an int instead of bool to avoid
* requiring stdbool.h. This should normally be set to true.
* When this is set to false, error detection is weaker.
*/
XZ_EXTERN void xz_dec_microlzma_reset(struct xz_dec_microlzma *s,
uint32_t comp_size, uint32_t uncomp_size,
int uncomp_size_is_exact);
/**
* xz_dec_microlzma_run() - Run the MicroLZMA decoder
* @s: Decoder state initialized using xz_dec_microlzma_reset()
* @b: Input and output buffers
*
* This works similarly to xz_dec_run() with a few important differences.
* Only the differences are documented here.
*
* The only possible return values are XZ_OK, XZ_STREAM_END, and
* XZ_DATA_ERROR. This function cannot return XZ_BUF_ERROR: if no progress
* is possible due to lack of input data or output space, this function will
* keep returning XZ_OK. Thus, the calling code must be written so that it
* will eventually provide input and output space matching (or exceeding)
* comp_size and uncomp_size arguments given to xz_dec_microlzma_reset().
* If the caller cannot do this (for example, if the input file is truncated
* or otherwise corrupt), the caller must detect this error by itself to
* avoid an infinite loop.
*
* If the compressed data seems to be corrupt, XZ_DATA_ERROR is returned.
* This can happen also when incorrect dictionary, uncompressed, or
* compressed sizes have been specified.
*
* With XZ_PREALLOC only: As an extra feature, b->out may be NULL to skip over
* uncompressed data. This way the caller doesn't need to provide a temporary
* output buffer for the bytes that will be ignored.
*
* With XZ_SINGLE only: In contrast to xz_dec_run(), the return value XZ_OK
* is also possible and thus XZ_SINGLE is actually a limited multi-call mode.
* After XZ_OK the bytes decoded so far may be read from the output buffer.
* It is possible to continue decoding but the variables b->out and b->out_pos
* MUST NOT be changed by the caller. Increasing the value of b->out_size is
* allowed to make more output space available; one doesn't need to provide
* space for the whole uncompressed data on the first call. The input buffer
* may be changed normally like with XZ_PREALLOC. This way input data can be
* provided from non-contiguous memory.
*/
XZ_EXTERN enum xz_ret xz_dec_microlzma_run(struct xz_dec_microlzma *s,
struct xz_buf *b);
/**
* xz_dec_microlzma_end() - Free the memory allocated for the decoder state
* @s: Decoder state allocated using xz_dec_microlzma_alloc().
* If s is NULL, this function does nothing.
*/
XZ_EXTERN void xz_dec_microlzma_end(struct xz_dec_microlzma *s);
/*
* Standalone build (userspace build or in-kernel build for boot time use)
* needs a CRC32 implementation. For normal in-kernel use, kernel's own
* CRC32 module is used instead, and users of this module don't need to
* care about the functions below.
*/
#ifndef XZ_INTERNAL_CRC32
# ifdef __KERNEL__
# define XZ_INTERNAL_CRC32 0
# else
# define XZ_INTERNAL_CRC32 1
# endif
#endif
/*
* If CRC64 support has been enabled with XZ_USE_CRC64, a CRC64
* implementation is needed too.
*/
#ifndef XZ_USE_CRC64
# undef XZ_INTERNAL_CRC64
# define XZ_INTERNAL_CRC64 0
#endif
#ifndef XZ_INTERNAL_CRC64
# ifdef __KERNEL__
# error Using CRC64 in the kernel has not been implemented.
# else
# define XZ_INTERNAL_CRC64 1
# endif
#endif
#if XZ_INTERNAL_CRC32
/*
* This must be called before any other xz_* function to initialize
* the CRC32 lookup table.
*/
XZ_EXTERN void xz_crc32_init(void);
/*
* Update CRC32 value using the polynomial from IEEE-802.3. To start a new
* calculation, the third argument must be zero. To continue the calculation,
* the previously returned value is passed as the third argument.
*/
XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc);
#endif
#if XZ_INTERNAL_CRC64
/*
* This must be called before any other xz_* function (except xz_crc32_init())
* to initialize the CRC64 lookup table.
*/
XZ_EXTERN void xz_crc64_init(void);
/*
* Update CRC64 value using the polynomial from ECMA-182. To start a new
* calculation, the third argument must be zero. To continue the calculation,
* the previously returned value is passed as the third argument.
*/
XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc);
#endif
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,138 @@
/* SPDX-License-Identifier: 0BSD */
/*
* Private includes and definitions for userspace use of XZ Embedded
*
* Author: Lasse Collin <lasse.collin@tukaani.org>
*/
#ifndef XZ_CONFIG_H
#define XZ_CONFIG_H
/* Uncomment to enable building of xz_dec_catrun(). */
/* #define XZ_DEC_CONCATENATED */
/* Uncomment to enable CRC64 support. */
/* #define XZ_USE_CRC64 */
/* Uncomment as needed to enable BCJ filter decoders. */
/* #define XZ_DEC_X86 */
/* #define XZ_DEC_ARM */
/* #define XZ_DEC_ARMTHUMB */
/* #define XZ_DEC_ARM64 */
/* #define XZ_DEC_RISCV */
/* #define XZ_DEC_POWERPC */
/* #define XZ_DEC_IA64 */
/* #define XZ_DEC_SPARC */
/*
* Visual Studio 2013 update 2 supports only __inline, not inline.
* MSVC v19.0 / VS 2015 and newer support both.
*/
#if defined(_MSC_VER) && _MSC_VER < 1900 && !defined(inline)
# define inline __inline
#endif
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#include "xz.h"
#define kmalloc(size, flags) malloc(size)
#define kfree(ptr) free(ptr)
#define vmalloc(size) malloc(size)
#define vfree(ptr) free(ptr)
#define memeq(a, b, size) (memcmp(a, b, size) == 0)
#define memzero(buf, size) memset(buf, 0, size)
#ifndef min
# define min(x, y) ((x) < (y) ? (x) : (y))
#endif
#define min_t(type, x, y) min(x, y)
#ifndef fallthrough
# if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311
# define fallthrough [[fallthrough]]
# elif (defined(__GNUC__) && __GNUC__ >= 7) \
|| (defined(__clang_major__) && __clang_major__ >= 10)
# define fallthrough __attribute__((__fallthrough__))
# else
# define fallthrough do {} while (0)
# endif
#endif
/*
* Some functions have been marked with __always_inline to keep the
* performance reasonable even when the compiler is optimizing for
* small code size. You may be able to save a few bytes by #defining
* __always_inline to plain inline, but don't complain if the code
* becomes slow.
*
* NOTE: System headers on GNU/Linux may #define this macro already,
* so if you want to change it, you need to #undef it first.
*/
#ifndef __always_inline
# ifdef __GNUC__
# define __always_inline \
inline __attribute__((__always_inline__))
# else
# define __always_inline inline
# endif
#endif
/* Inline functions to access unaligned unsigned 32-bit integers */
#ifndef get_unaligned_le32
static inline uint32_t get_unaligned_le32(const uint8_t *buf)
{
return (uint32_t)buf[0]
| ((uint32_t)buf[1] << 8)
| ((uint32_t)buf[2] << 16)
| ((uint32_t)buf[3] << 24);
}
#endif
#ifndef get_unaligned_be32
static inline uint32_t get_unaligned_be32(const uint8_t *buf)
{
return (uint32_t)((uint32_t)buf[0] << 24)
| ((uint32_t)buf[1] << 16)
| ((uint32_t)buf[2] << 8)
| (uint32_t)buf[3];
}
#endif
#ifndef put_unaligned_le32
static inline void put_unaligned_le32(uint32_t val, uint8_t *buf)
{
buf[0] = (uint8_t)val;
buf[1] = (uint8_t)(val >> 8);
buf[2] = (uint8_t)(val >> 16);
buf[3] = (uint8_t)(val >> 24);
}
#endif
#ifndef put_unaligned_be32
static inline void put_unaligned_be32(uint32_t val, uint8_t *buf)
{
buf[0] = (uint8_t)(val >> 24);
buf[1] = (uint8_t)(val >> 16);
buf[2] = (uint8_t)(val >> 8);
buf[3] = (uint8_t)val;
}
#endif
/*
* To keep things simpler, use the generic unaligned methods also for
* aligned access. The only place where performance could matter is
* SHA-256 but files using SHA-256 aren't common.
*/
#ifndef get_le32
# define get_le32 get_unaligned_le32
#endif
#ifndef get_be32
# define get_be32 get_unaligned_be32
#endif
#endif

View File

@@ -0,0 +1,58 @@
// SPDX-License-Identifier: 0BSD
/*
* CRC32 using the polynomial from IEEE-802.3
*
* Authors: Lasse Collin <lasse.collin@tukaani.org>
* Igor Pavlov <https://7-zip.org/>
*/
/*
* This is not the fastest implementation, but it is pretty compact.
* The fastest versions of xz_crc32() on modern CPUs without hardware
* accelerated CRC instruction are 3-5 times as fast as this version,
* but they are bigger and use more memory for the lookup table.
*/
#include "xz_private.h"
/*
* STATIC_RW_DATA is used in the pre-boot environment on some architectures.
* See <linux/decompress/mm.h> for details.
*/
#ifndef STATIC_RW_DATA
# define STATIC_RW_DATA static
#endif
STATIC_RW_DATA uint32_t xz_crc32_table[256];
XZ_EXTERN void xz_crc32_init(void)
{
const uint32_t poly = 0xEDB88320;
uint32_t i;
uint32_t j;
uint32_t r;
for (i = 0; i < 256; ++i) {
r = i;
for (j = 0; j < 8; ++j)
r = (r >> 1) ^ (poly & ~((r & 1) - 1));
xz_crc32_table[i] = r;
}
return;
}
XZ_EXTERN uint32_t xz_crc32(const uint8_t *buf, size_t size, uint32_t crc)
{
crc = ~crc;
while (size != 0) {
crc = xz_crc32_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8);
--size;
}
return ~crc;
}

View File

@@ -0,0 +1,53 @@
// SPDX-License-Identifier: 0BSD
/*
* CRC64 using the polynomial from ECMA-182
*
* This file is similar to xz_crc32.c. See the comments there.
*
* Authors: Lasse Collin <lasse.collin@tukaani.org>
* Igor Pavlov <https://7-zip.org/>
*/
#include "xz_private.h"
#ifndef STATIC_RW_DATA
# define STATIC_RW_DATA static
#endif
STATIC_RW_DATA uint64_t xz_crc64_table[256];
XZ_EXTERN void xz_crc64_init(void)
{
/*
* The ULL suffix is needed for -std=gnu89 compatibility
* on 32-bit platforms.
*/
const uint64_t poly = 0xC96C5795D7870F42ULL;
uint32_t i;
uint32_t j;
uint64_t r;
for (i = 0; i < 256; ++i) {
r = i;
for (j = 0; j < 8; ++j)
r = (r >> 1) ^ (poly & ~((r & 1) - 1));
xz_crc64_table[i] = r;
}
return;
}
XZ_EXTERN uint64_t xz_crc64(const uint8_t *buf, size_t size, uint64_t crc)
{
crc = ~crc;
while (size != 0) {
crc = xz_crc64_table[*buf++ ^ (crc & 0xFF)] ^ (crc >> 8);
--size;
}
return ~crc;
}

View File

@@ -0,0 +1,738 @@
// SPDX-License-Identifier: 0BSD
/*
* Branch/Call/Jump (BCJ) filter decoders
*
* Authors: Lasse Collin <lasse.collin@tukaani.org>
* Igor Pavlov <https://7-zip.org/>
*/
#include "xz_private.h"
/*
* The rest of the file is inside this ifdef. It makes things a little more
* convenient when building without support for any BCJ filters.
*/
#ifdef XZ_DEC_BCJ
struct xz_dec_bcj {
/* Type of the BCJ filter being used */
enum {
BCJ_X86 = 4, /* x86 or x86-64 */
BCJ_POWERPC = 5, /* Big endian only */
BCJ_IA64 = 6, /* Big or little endian */
BCJ_ARM = 7, /* Little endian only */
BCJ_ARMTHUMB = 8, /* Little endian only */
BCJ_SPARC = 9, /* Big or little endian */
BCJ_ARM64 = 10, /* AArch64 */
BCJ_RISCV = 11 /* RV32GQC_Zfh, RV64GQC_Zfh */
} type;
/*
* Return value of the next filter in the chain. We need to preserve
* this information across calls, because we must not call the next
* filter anymore once it has returned XZ_STREAM_END.
*/
enum xz_ret ret;
/* True if we are operating in single-call mode. */
bool single_call;
/*
* Absolute position relative to the beginning of the uncompressed
* data (in a single .xz Block). We care only about the lowest 32
* bits so this doesn't need to be uint64_t even with big files.
*/
uint32_t pos;
/* x86 filter state */
uint32_t x86_prev_mask;
/* Temporary space to hold the variables from struct xz_buf */
uint8_t *out;
size_t out_pos;
size_t out_size;
struct {
/* Amount of already filtered data in the beginning of buf */
size_t filtered;
/* Total amount of data currently stored in buf */
size_t size;
/*
* Buffer to hold a mix of filtered and unfiltered data. This
* needs to be big enough to hold Alignment + 2 * Look-ahead:
*
* Type Alignment Look-ahead
* x86 1 4
* PowerPC 4 0
* IA-64 16 0
* ARM 4 0
* ARM-Thumb 2 2
* SPARC 4 0
*/
uint8_t buf[16];
} temp;
};
#ifdef XZ_DEC_X86
/*
* This is used to test the most significant byte of a memory address
* in an x86 instruction.
*/
static inline int bcj_x86_test_msbyte(uint8_t b)
{
return b == 0x00 || b == 0xFF;
}
static size_t bcj_x86(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
static const bool mask_to_allowed_status[8]
= { true, true, true, false, true, false, false, false };
static const uint8_t mask_to_bit_num[8] = { 0, 1, 2, 2, 3, 3, 3, 3 };
size_t i;
size_t prev_pos = (size_t)-1;
uint32_t prev_mask = s->x86_prev_mask;
uint32_t src;
uint32_t dest;
uint32_t j;
uint8_t b;
if (size <= 4)
return 0;
size -= 4;
for (i = 0; i < size; ++i) {
if ((buf[i] & 0xFE) != 0xE8)
continue;
prev_pos = i - prev_pos;
if (prev_pos > 3) {
prev_mask = 0;
} else {
prev_mask = (prev_mask << (prev_pos - 1)) & 7;
if (prev_mask != 0) {
b = buf[i + 4 - mask_to_bit_num[prev_mask]];
if (!mask_to_allowed_status[prev_mask]
|| bcj_x86_test_msbyte(b)) {
prev_pos = i;
prev_mask = (prev_mask << 1) | 1;
continue;
}
}
}
prev_pos = i;
if (bcj_x86_test_msbyte(buf[i + 4])) {
src = get_unaligned_le32(buf + i + 1);
while (true) {
dest = src - (s->pos + (uint32_t)i + 5);
if (prev_mask == 0)
break;
j = mask_to_bit_num[prev_mask] * 8;
b = (uint8_t)(dest >> (24 - j));
if (!bcj_x86_test_msbyte(b))
break;
src = dest ^ (((uint32_t)1 << (32 - j)) - 1);
}
dest &= 0x01FFFFFF;
dest |= (uint32_t)0 - (dest & 0x01000000);
put_unaligned_le32(dest, buf + i + 1);
i += 4;
} else {
prev_mask = (prev_mask << 1) | 1;
}
}
prev_pos = i - prev_pos;
s->x86_prev_mask = prev_pos > 3 ? 0 : prev_mask << (prev_pos - 1);
return i;
}
#endif
#ifdef XZ_DEC_POWERPC
static size_t bcj_powerpc(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t instr;
size &= ~(size_t)3;
for (i = 0; i < size; i += 4) {
instr = get_unaligned_be32(buf + i);
if ((instr & 0xFC000003) == 0x48000001) {
instr &= 0x03FFFFFC;
instr -= s->pos + (uint32_t)i;
instr &= 0x03FFFFFC;
instr |= 0x48000001;
put_unaligned_be32(instr, buf + i);
}
}
return i;
}
#endif
#ifdef XZ_DEC_IA64
static size_t bcj_ia64(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
static const uint8_t branch_table[32] = {
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
4, 4, 6, 6, 0, 0, 7, 7,
4, 4, 0, 0, 4, 4, 0, 0
};
/*
* The local variables take a little bit stack space, but it's less
* than what LZMA2 decoder takes, so it doesn't make sense to reduce
* stack usage here without doing that for the LZMA2 decoder too.
*/
/* Loop counters */
size_t i;
size_t j;
/* Instruction slot (0, 1, or 2) in the 128-bit instruction word */
uint32_t slot;
/* Bitwise offset of the instruction indicated by slot */
uint32_t bit_pos;
/* bit_pos split into byte and bit parts */
uint32_t byte_pos;
uint32_t bit_res;
/* Address part of an instruction */
uint32_t addr;
/* Mask used to detect which instructions to convert */
uint32_t mask;
/* 41-bit instruction stored somewhere in the lowest 48 bits */
uint64_t instr;
/* Instruction normalized with bit_res for easier manipulation */
uint64_t norm;
size &= ~(size_t)15;
for (i = 0; i < size; i += 16) {
mask = branch_table[buf[i] & 0x1F];
for (slot = 0, bit_pos = 5; slot < 3; ++slot, bit_pos += 41) {
if (((mask >> slot) & 1) == 0)
continue;
byte_pos = bit_pos >> 3;
bit_res = bit_pos & 7;
instr = 0;
for (j = 0; j < 6; ++j)
instr |= (uint64_t)(buf[i + j + byte_pos])
<< (8 * j);
norm = instr >> bit_res;
if (((norm >> 37) & 0x0F) == 0x05
&& ((norm >> 9) & 0x07) == 0) {
addr = (norm >> 13) & 0x0FFFFF;
addr |= ((uint32_t)(norm >> 36) & 1) << 20;
addr <<= 4;
addr -= s->pos + (uint32_t)i;
addr >>= 4;
norm &= ~((uint64_t)0x8FFFFF << 13);
norm |= (uint64_t)(addr & 0x0FFFFF) << 13;
norm |= (uint64_t)(addr & 0x100000)
<< (36 - 20);
instr &= (1 << bit_res) - 1;
instr |= norm << bit_res;
for (j = 0; j < 6; j++)
buf[i + j + byte_pos]
= (uint8_t)(instr >> (8 * j));
}
}
}
return i;
}
#endif
#ifdef XZ_DEC_ARM
static size_t bcj_arm(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t addr;
size &= ~(size_t)3;
for (i = 0; i < size; i += 4) {
if (buf[i + 3] == 0xEB) {
addr = (uint32_t)buf[i] | ((uint32_t)buf[i + 1] << 8)
| ((uint32_t)buf[i + 2] << 16);
addr <<= 2;
addr -= s->pos + (uint32_t)i + 8;
addr >>= 2;
buf[i] = (uint8_t)addr;
buf[i + 1] = (uint8_t)(addr >> 8);
buf[i + 2] = (uint8_t)(addr >> 16);
}
}
return i;
}
#endif
#ifdef XZ_DEC_ARMTHUMB
static size_t bcj_armthumb(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t addr;
if (size < 4)
return 0;
size -= 4;
for (i = 0; i <= size; i += 2) {
if ((buf[i + 1] & 0xF8) == 0xF0
&& (buf[i + 3] & 0xF8) == 0xF8) {
addr = (((uint32_t)buf[i + 1] & 0x07) << 19)
| ((uint32_t)buf[i] << 11)
| (((uint32_t)buf[i + 3] & 0x07) << 8)
| (uint32_t)buf[i + 2];
addr <<= 1;
addr -= s->pos + (uint32_t)i + 4;
addr >>= 1;
buf[i + 1] = (uint8_t)(0xF0 | ((addr >> 19) & 0x07));
buf[i] = (uint8_t)(addr >> 11);
buf[i + 3] = (uint8_t)(0xF8 | ((addr >> 8) & 0x07));
buf[i + 2] = (uint8_t)addr;
i += 2;
}
}
return i;
}
#endif
#ifdef XZ_DEC_SPARC
static size_t bcj_sparc(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t instr;
size &= ~(size_t)3;
for (i = 0; i < size; i += 4) {
instr = get_unaligned_be32(buf + i);
if ((instr >> 22) == 0x100 || (instr >> 22) == 0x1FF) {
instr <<= 2;
instr -= s->pos + (uint32_t)i;
instr >>= 2;
instr = ((uint32_t)0x40000000 - (instr & 0x400000))
| 0x40000000 | (instr & 0x3FFFFF);
put_unaligned_be32(instr, buf + i);
}
}
return i;
}
#endif
#ifdef XZ_DEC_ARM64
static size_t bcj_arm64(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t instr;
uint32_t addr;
size &= ~(size_t)3;
for (i = 0; i < size; i += 4) {
instr = get_unaligned_le32(buf + i);
if ((instr >> 26) == 0x25) {
/* BL instruction */
addr = instr - ((s->pos + (uint32_t)i) >> 2);
instr = 0x94000000 | (addr & 0x03FFFFFF);
put_unaligned_le32(instr, buf + i);
} else if ((instr & 0x9F000000) == 0x90000000) {
/* ADRP instruction */
addr = ((instr >> 29) & 3) | ((instr >> 3) & 0x1FFFFC);
/* Only convert values in the range +/-512 MiB. */
if ((addr + 0x020000) & 0x1C0000)
continue;
addr -= (s->pos + (uint32_t)i) >> 12;
instr &= 0x9000001F;
instr |= (addr & 3) << 29;
instr |= (addr & 0x03FFFC) << 3;
instr |= (0U - (addr & 0x020000)) & 0xE00000;
put_unaligned_le32(instr, buf + i);
}
}
return i;
}
#endif
#ifdef XZ_DEC_RISCV
static size_t bcj_riscv(struct xz_dec_bcj *s, uint8_t *buf, size_t size)
{
size_t i;
uint32_t b1;
uint32_t b2;
uint32_t b3;
uint32_t instr;
uint32_t instr2;
uint32_t instr2_rs1;
uint32_t addr;
if (size < 8)
return 0;
size -= 8;
for (i = 0; i <= size; i += 2) {
instr = buf[i];
if (instr == 0xEF) {
/* JAL */
b1 = buf[i + 1];
if ((b1 & 0x0D) != 0)
continue;
b2 = buf[i + 2];
b3 = buf[i + 3];
addr = ((b1 & 0xF0) << 13) | (b2 << 9) | (b3 << 1);
addr -= s->pos + (uint32_t)i;
buf[i + 1] = (uint8_t)((b1 & 0x0F)
| ((addr >> 8) & 0xF0));
buf[i + 2] = (uint8_t)(((addr >> 16) & 0x0F)
| ((addr >> 7) & 0x10)
| ((addr << 4) & 0xE0));
buf[i + 3] = (uint8_t)(((addr >> 4) & 0x7F)
| ((addr >> 13) & 0x80));
i += 4 - 2;
} else if ((instr & 0x7F) == 0x17) {
/* AUIPC */
instr |= (uint32_t)buf[i + 1] << 8;
instr |= (uint32_t)buf[i + 2] << 16;
instr |= (uint32_t)buf[i + 3] << 24;
if (instr & 0xE80) {
/* AUIPC's rd doesn't equal x0 or x2. */
instr2 = get_unaligned_le32(buf + i + 4);
if (((instr << 8) ^ (instr2 - 3)) & 0xF8003) {
i += 6 - 2;
continue;
}
addr = (instr & 0xFFFFF000) + (instr2 >> 20);
instr = 0x17 | (2 << 7) | (instr2 << 12);
instr2 = addr;
} else {
/* AUIPC's rd equals x0 or x2. */
instr2_rs1 = instr >> 27;
if ((uint32_t)((instr - 0x3117) << 18)
>= (instr2_rs1 & 0x1D)) {
i += 4 - 2;
continue;
}
addr = get_unaligned_be32(buf + i + 4);
addr -= s->pos + (uint32_t)i;
instr2 = (instr >> 12) | (addr << 20);
instr = 0x17 | (instr2_rs1 << 7)
| ((addr + 0x800) & 0xFFFFF000);
}
put_unaligned_le32(instr, buf + i);
put_unaligned_le32(instr2, buf + i + 4);
i += 8 - 2;
}
}
return i;
}
#endif
/*
* Apply the selected BCJ filter. Update *pos and s->pos to match the amount
* of data that got filtered.
*
* NOTE: This is implemented as a switch statement to avoid using function
* pointers, which could be problematic in the kernel boot code, which must
* avoid pointers to static data (at least on x86).
*/
static void bcj_apply(struct xz_dec_bcj *s,
uint8_t *buf, size_t *pos, size_t size)
{
size_t filtered;
buf += *pos;
size -= *pos;
switch (s->type) {
#ifdef XZ_DEC_X86
case BCJ_X86:
filtered = bcj_x86(s, buf, size);
break;
#endif
#ifdef XZ_DEC_POWERPC
case BCJ_POWERPC:
filtered = bcj_powerpc(s, buf, size);
break;
#endif
#ifdef XZ_DEC_IA64
case BCJ_IA64:
filtered = bcj_ia64(s, buf, size);
break;
#endif
#ifdef XZ_DEC_ARM
case BCJ_ARM:
filtered = bcj_arm(s, buf, size);
break;
#endif
#ifdef XZ_DEC_ARMTHUMB
case BCJ_ARMTHUMB:
filtered = bcj_armthumb(s, buf, size);
break;
#endif
#ifdef XZ_DEC_SPARC
case BCJ_SPARC:
filtered = bcj_sparc(s, buf, size);
break;
#endif
#ifdef XZ_DEC_ARM64
case BCJ_ARM64:
filtered = bcj_arm64(s, buf, size);
break;
#endif
#ifdef XZ_DEC_RISCV
case BCJ_RISCV:
filtered = bcj_riscv(s, buf, size);
break;
#endif
default:
/* Never reached but silence compiler warnings. */
filtered = 0;
break;
}
*pos += filtered;
s->pos += filtered;
}
/*
* Flush pending filtered data from temp to the output buffer.
* Move the remaining mixture of possibly filtered and unfiltered
* data to the beginning of temp.
*/
static void bcj_flush(struct xz_dec_bcj *s, struct xz_buf *b)
{
size_t copy_size;
copy_size = min_t(size_t, s->temp.filtered, b->out_size - b->out_pos);
memcpy(b->out + b->out_pos, s->temp.buf, copy_size);
b->out_pos += copy_size;
s->temp.filtered -= copy_size;
s->temp.size -= copy_size;
memmove(s->temp.buf, s->temp.buf + copy_size, s->temp.size);
}
/*
* The BCJ filter functions are primitive in sense that they process the
* data in chunks of 1-16 bytes. To hide this issue, this function does
* some buffering.
*/
XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s,
struct xz_dec_lzma2 *lzma2,
struct xz_buf *b)
{
size_t out_start;
/*
* Flush pending already filtered data to the output buffer. Return
* immediately if we couldn't flush everything, or if the next
* filter in the chain had already returned XZ_STREAM_END.
*/
if (s->temp.filtered > 0) {
bcj_flush(s, b);
if (s->temp.filtered > 0)
return XZ_OK;
if (s->ret == XZ_STREAM_END)
return XZ_STREAM_END;
}
/*
* If we have more output space than what is currently pending in
* temp, copy the unfiltered data from temp to the output buffer
* and try to fill the output buffer by decoding more data from the
* next filter in the chain. Apply the BCJ filter on the new data
* in the output buffer. If everything cannot be filtered, copy it
* to temp and rewind the output buffer position accordingly.
*
* This needs to be always run when temp.size == 0 to handle a special
* case where the output buffer is full and the next filter has no
* more output coming but hasn't returned XZ_STREAM_END yet.
*/
if (s->temp.size < b->out_size - b->out_pos || s->temp.size == 0) {
out_start = b->out_pos;
memcpy(b->out + b->out_pos, s->temp.buf, s->temp.size);
b->out_pos += s->temp.size;
s->ret = xz_dec_lzma2_run(lzma2, b);
if (s->ret != XZ_STREAM_END
&& (s->ret != XZ_OK || s->single_call))
return s->ret;
bcj_apply(s, b->out, &out_start, b->out_pos);
/*
* As an exception, if the next filter returned XZ_STREAM_END,
* we can do that too, since the last few bytes that remain
* unfiltered are meant to remain unfiltered.
*/
if (s->ret == XZ_STREAM_END)
return XZ_STREAM_END;
s->temp.size = b->out_pos - out_start;
b->out_pos -= s->temp.size;
memcpy(s->temp.buf, b->out + b->out_pos, s->temp.size);
/*
* If there wasn't enough input to the next filter to fill
* the output buffer with unfiltered data, there's no point
* to try decoding more data to temp.
*/
if (b->out_pos + s->temp.size < b->out_size)
return XZ_OK;
}
/*
* We have unfiltered data in temp. If the output buffer isn't full
* yet, try to fill the temp buffer by decoding more data from the
* next filter. Apply the BCJ filter on temp. Then we hopefully can
* fill the actual output buffer by copying filtered data from temp.
* A mix of filtered and unfiltered data may be left in temp; it will
* be taken care on the next call to this function.
*/
if (b->out_pos < b->out_size) {
/* Make b->out{,_pos,_size} temporarily point to s->temp. */
s->out = b->out;
s->out_pos = b->out_pos;
s->out_size = b->out_size;
b->out = s->temp.buf;
b->out_pos = s->temp.size;
b->out_size = sizeof(s->temp.buf);
s->ret = xz_dec_lzma2_run(lzma2, b);
s->temp.size = b->out_pos;
b->out = s->out;
b->out_pos = s->out_pos;
b->out_size = s->out_size;
if (s->ret != XZ_OK && s->ret != XZ_STREAM_END)
return s->ret;
bcj_apply(s, s->temp.buf, &s->temp.filtered, s->temp.size);
/*
* If the next filter returned XZ_STREAM_END, we mark that
* everything is filtered, since the last unfiltered bytes
* of the stream are meant to be left as is.
*/
if (s->ret == XZ_STREAM_END)
s->temp.filtered = s->temp.size;
bcj_flush(s, b);
if (s->temp.filtered > 0)
return XZ_OK;
}
return s->ret;
}
XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call)
{
struct xz_dec_bcj *s = kmalloc(sizeof(*s), GFP_KERNEL);
if (s != NULL)
s->single_call = single_call;
return s;
}
XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id)
{
switch (id) {
#ifdef XZ_DEC_X86
case BCJ_X86:
#endif
#ifdef XZ_DEC_POWERPC
case BCJ_POWERPC:
#endif
#ifdef XZ_DEC_IA64
case BCJ_IA64:
#endif
#ifdef XZ_DEC_ARM
case BCJ_ARM:
#endif
#ifdef XZ_DEC_ARMTHUMB
case BCJ_ARMTHUMB:
#endif
#ifdef XZ_DEC_SPARC
case BCJ_SPARC:
#endif
#ifdef XZ_DEC_ARM64
case BCJ_ARM64:
#endif
#ifdef XZ_DEC_RISCV
case BCJ_RISCV:
#endif
break;
default:
/* Unsupported Filter ID */
return XZ_OPTIONS_ERROR;
}
s->type = id;
s->ret = XZ_OK;
s->pos = 0;
s->x86_prev_mask = 0;
s->temp.filtered = 0;
s->temp.size = 0;
return XZ_OK;
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,984 @@
// SPDX-License-Identifier: 0BSD
/*
* .xz Stream decoder
*
* Author: Lasse Collin <lasse.collin@tukaani.org>
*/
#include "xz_private.h"
#include "xz_stream.h"
#ifdef XZ_USE_CRC64
# define IS_CRC64(check_type) ((check_type) == XZ_CHECK_CRC64)
#else
# define IS_CRC64(check_type) false
#endif
#ifdef XZ_USE_SHA256
# define IS_SHA256(check_type) ((check_type) == XZ_CHECK_SHA256)
#else
# define IS_SHA256(check_type) false
#endif
/* Hash used to validate the Index field */
struct xz_dec_hash {
vli_type unpadded;
vli_type uncompressed;
uint32_t crc32;
};
struct xz_dec {
/* Position in dec_main() */
enum {
SEQ_STREAM_HEADER,
SEQ_BLOCK_START,
SEQ_BLOCK_HEADER,
SEQ_BLOCK_UNCOMPRESS,
SEQ_BLOCK_PADDING,
SEQ_BLOCK_CHECK,
SEQ_INDEX,
SEQ_INDEX_PADDING,
SEQ_INDEX_CRC32,
SEQ_STREAM_FOOTER,
SEQ_STREAM_PADDING
} sequence;
/* Position in variable-length integers and Check fields */
uint32_t pos;
/* Variable-length integer decoded by dec_vli() */
vli_type vli;
/* Saved in_pos and out_pos */
size_t in_start;
size_t out_start;
#ifdef XZ_USE_CRC64
/* CRC32 or CRC64 value in Block or CRC32 value in Index */
uint64_t crc;
#else
/* CRC32 value in Block or Index */
uint32_t crc;
#endif
/* Type of the integrity check calculated from uncompressed data */
enum xz_check check_type;
/* Operation mode */
enum xz_mode mode;
/*
* True if the next call to xz_dec_run() is allowed to return
* XZ_BUF_ERROR.
*/
bool allow_buf_error;
/* Information stored in Block Header */
struct {
/*
* Value stored in the Compressed Size field, or
* VLI_UNKNOWN if Compressed Size is not present.
*/
vli_type compressed;
/*
* Value stored in the Uncompressed Size field, or
* VLI_UNKNOWN if Uncompressed Size is not present.
*/
vli_type uncompressed;
/* Size of the Block Header field */
uint32_t size;
} block_header;
/* Information collected when decoding Blocks */
struct {
/* Observed compressed size of the current Block */
vli_type compressed;
/* Observed uncompressed size of the current Block */
vli_type uncompressed;
/* Number of Blocks decoded so far */
vli_type count;
/*
* Hash calculated from the Block sizes. This is used to
* validate the Index field.
*/
struct xz_dec_hash hash;
} block;
/* Variables needed when verifying the Index field */
struct {
/* Position in dec_index() */
enum {
SEQ_INDEX_COUNT,
SEQ_INDEX_UNPADDED,
SEQ_INDEX_UNCOMPRESSED
} sequence;
/* Size of the Index in bytes */
vli_type size;
/* Number of Records (matches block.count in valid files) */
vli_type count;
/*
* Hash calculated from the Records (matches block.hash in
* valid files).
*/
struct xz_dec_hash hash;
} index;
/*
* Temporary buffer needed to hold Stream Header, Block Header,
* and Stream Footer. The Block Header is the biggest (1 KiB)
* so we reserve space according to that. buf[] has to be aligned
* to a multiple of four bytes; the size_t variables before it
* should guarantee this.
*/
struct {
size_t pos;
size_t size;
uint8_t buf[1024];
} temp;
struct xz_dec_lzma2 *lzma2;
#ifdef XZ_DEC_BCJ
struct xz_dec_bcj *bcj;
bool bcj_active;
#endif
#ifdef XZ_USE_SHA256
/*
* SHA-256 value in Block
*
* struct xz_sha256 is over a hundred bytes and it's only accessed
* from a few places. By putting the SHA-256 state near the end
* of struct xz_dec (somewhere after the "index" member) reduces
* code size at least on x86 and RISC-V. It's because the first bytes
* of the struct can be accessed with smaller instructions; the
* members that are accessed from many places should be at the top.
*/
struct xz_sha256 sha256;
#endif
};
#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256)
/* Sizes of the Check field with different Check IDs */
static const uint8_t check_sizes[16] = {
0,
4, 4, 4,
8, 8, 8,
16, 16, 16,
32, 32, 32,
64, 64, 64
};
#endif
/*
* Fill s->temp by copying data starting from b->in[b->in_pos]. Caller
* must have set s->temp.pos and s->temp.size to indicate how much data
* we are supposed to copy into s->temp.buf. Return true once s->temp.pos
* has reached s->temp.size.
*/
static bool fill_temp(struct xz_dec *s, struct xz_buf *b)
{
size_t copy_size = min_t(size_t,
b->in_size - b->in_pos, s->temp.size - s->temp.pos);
memcpy(s->temp.buf + s->temp.pos, b->in + b->in_pos, copy_size);
b->in_pos += copy_size;
s->temp.pos += copy_size;
if (s->temp.pos == s->temp.size) {
s->temp.pos = 0;
return true;
}
return false;
}
/* Decode a variable-length integer (little-endian base-128 encoding) */
static enum xz_ret dec_vli(struct xz_dec *s, const uint8_t *in,
size_t *in_pos, size_t in_size)
{
uint8_t byte;
if (s->pos == 0)
s->vli = 0;
while (*in_pos < in_size) {
byte = in[*in_pos];
++*in_pos;
s->vli |= (vli_type)(byte & 0x7F) << s->pos;
if ((byte & 0x80) == 0) {
/* Don't allow non-minimal encodings. */
if (byte == 0 && s->pos != 0)
return XZ_DATA_ERROR;
s->pos = 0;
return XZ_STREAM_END;
}
s->pos += 7;
if (s->pos == 7 * VLI_BYTES_MAX)
return XZ_DATA_ERROR;
}
return XZ_OK;
}
/*
* Decode the Compressed Data field from a Block. Update and validate
* the observed compressed and uncompressed sizes of the Block so that
* they don't exceed the values possibly stored in the Block Header
* (validation assumes that no integer overflow occurs, since vli_type
* is normally uint64_t). Update the CRC32 or CRC64 value if presence of
* the CRC32 or CRC64 field was indicated in Stream Header.
*
* Once the decoding is finished, validate that the observed sizes match
* the sizes possibly stored in the Block Header. Update the hash and
* Block count, which are later used to validate the Index field.
*/
static enum xz_ret dec_block(struct xz_dec *s, struct xz_buf *b)
{
enum xz_ret ret;
s->in_start = b->in_pos;
s->out_start = b->out_pos;
#ifdef XZ_DEC_BCJ
if (s->bcj_active)
ret = xz_dec_bcj_run(s->bcj, s->lzma2, b);
else
#endif
ret = xz_dec_lzma2_run(s->lzma2, b);
s->block.compressed += b->in_pos - s->in_start;
s->block.uncompressed += b->out_pos - s->out_start;
/*
* There is no need to separately check for VLI_UNKNOWN, since
* the observed sizes are always smaller than VLI_UNKNOWN.
*/
if (s->block.compressed > s->block_header.compressed
|| s->block.uncompressed
> s->block_header.uncompressed)
return XZ_DATA_ERROR;
if (s->check_type == XZ_CHECK_CRC32)
s->crc = xz_crc32(b->out + s->out_start,
b->out_pos - s->out_start, s->crc);
#ifdef XZ_USE_CRC64
else if (s->check_type == XZ_CHECK_CRC64)
s->crc = xz_crc64(b->out + s->out_start,
b->out_pos - s->out_start, s->crc);
#endif
#ifdef XZ_USE_SHA256
else if (s->check_type == XZ_CHECK_SHA256)
xz_sha256_update(b->out + s->out_start,
b->out_pos - s->out_start, &s->sha256);
#endif
if (ret == XZ_STREAM_END) {
if (s->block_header.compressed != VLI_UNKNOWN
&& s->block_header.compressed
!= s->block.compressed)
return XZ_DATA_ERROR;
if (s->block_header.uncompressed != VLI_UNKNOWN
&& s->block_header.uncompressed
!= s->block.uncompressed)
return XZ_DATA_ERROR;
s->block.hash.unpadded += s->block_header.size
+ s->block.compressed;
#if defined(XZ_DEC_ANY_CHECK) || defined(XZ_USE_SHA256)
s->block.hash.unpadded += check_sizes[s->check_type];
#else
if (s->check_type == XZ_CHECK_CRC32)
s->block.hash.unpadded += 4;
else if (IS_CRC64(s->check_type))
s->block.hash.unpadded += 8;
#endif
s->block.hash.uncompressed += s->block.uncompressed;
s->block.hash.crc32 = xz_crc32(
(const uint8_t *)&s->block.hash,
sizeof(s->block.hash), s->block.hash.crc32);
++s->block.count;
}
return ret;
}
/* Update the Index size and the CRC32 value. */
static void index_update(struct xz_dec *s, const struct xz_buf *b)
{
size_t in_used = b->in_pos - s->in_start;
s->index.size += in_used;
s->crc = xz_crc32(b->in + s->in_start, in_used, s->crc);
}
/*
* Decode the Number of Records, Unpadded Size, and Uncompressed Size
* fields from the Index field. That is, Index Padding and CRC32 are not
* decoded by this function.
*
* This can return XZ_OK (more input needed), XZ_STREAM_END (everything
* successfully decoded), or XZ_DATA_ERROR (input is corrupt).
*/
static enum xz_ret dec_index(struct xz_dec *s, struct xz_buf *b)
{
enum xz_ret ret;
do {
ret = dec_vli(s, b->in, &b->in_pos, b->in_size);
if (ret != XZ_STREAM_END) {
index_update(s, b);
return ret;
}
switch (s->index.sequence) {
case SEQ_INDEX_COUNT:
s->index.count = s->vli;
/*
* Validate that the Number of Records field
* indicates the same number of Records as
* there were Blocks in the Stream.
*/
if (s->index.count != s->block.count)
return XZ_DATA_ERROR;
s->index.sequence = SEQ_INDEX_UNPADDED;
break;
case SEQ_INDEX_UNPADDED:
s->index.hash.unpadded += s->vli;
s->index.sequence = SEQ_INDEX_UNCOMPRESSED;
break;
case SEQ_INDEX_UNCOMPRESSED:
s->index.hash.uncompressed += s->vli;
s->index.hash.crc32 = xz_crc32(
(const uint8_t *)&s->index.hash,
sizeof(s->index.hash),
s->index.hash.crc32);
--s->index.count;
s->index.sequence = SEQ_INDEX_UNPADDED;
break;
}
} while (s->index.count > 0);
return XZ_STREAM_END;
}
/*
* Validate that the next four or eight input bytes match the value
* of s->crc. s->pos must be zero when starting to validate the first byte.
* The "bits" argument allows using the same code for both CRC32 and CRC64.
*/
static enum xz_ret crc_validate(struct xz_dec *s, struct xz_buf *b,
uint32_t bits)
{
do {
if (b->in_pos == b->in_size)
return XZ_OK;
if (((s->crc >> s->pos) & 0xFF) != b->in[b->in_pos++])
return XZ_DATA_ERROR;
s->pos += 8;
} while (s->pos < bits);
s->crc = 0;
s->pos = 0;
return XZ_STREAM_END;
}
#ifdef XZ_DEC_ANY_CHECK
/*
* Skip over the Check field when the Check ID is not supported.
* Returns true once the whole Check field has been skipped over.
*/
static bool check_skip(struct xz_dec *s, struct xz_buf *b)
{
while (s->pos < check_sizes[s->check_type]) {
if (b->in_pos == b->in_size)
return false;
++b->in_pos;
++s->pos;
}
s->pos = 0;
return true;
}
#endif
/* Decode the Stream Header field (the first 12 bytes of the .xz Stream). */
static enum xz_ret dec_stream_header(struct xz_dec *s)
{
if (!memeq(s->temp.buf, HEADER_MAGIC, HEADER_MAGIC_SIZE))
return XZ_FORMAT_ERROR;
if (xz_crc32(s->temp.buf + HEADER_MAGIC_SIZE, 2, 0)
!= get_le32(s->temp.buf + HEADER_MAGIC_SIZE + 2))
return XZ_DATA_ERROR;
if (s->temp.buf[HEADER_MAGIC_SIZE] != 0)
return XZ_OPTIONS_ERROR;
/*
* Of integrity checks, we support none (Check ID = 0),
* CRC32 (Check ID = 1), and optionally CRC64 (Check ID = 4).
* However, if XZ_DEC_ANY_CHECK is defined, we will accept other
* check types too, but then the check won't be verified and
* a warning (XZ_UNSUPPORTED_CHECK) will be given.
*/
if (s->temp.buf[HEADER_MAGIC_SIZE + 1] > XZ_CHECK_MAX)
return XZ_OPTIONS_ERROR;
s->check_type = s->temp.buf[HEADER_MAGIC_SIZE + 1];
if (s->check_type > XZ_CHECK_CRC32 && !IS_CRC64(s->check_type)
&& !IS_SHA256(s->check_type)) {
#ifdef XZ_DEC_ANY_CHECK
return XZ_UNSUPPORTED_CHECK;
#else
return XZ_OPTIONS_ERROR;
#endif
}
return XZ_OK;
}
/* Decode the Stream Footer field (the last 12 bytes of the .xz Stream) */
static enum xz_ret dec_stream_footer(struct xz_dec *s)
{
if (!memeq(s->temp.buf + 10, FOOTER_MAGIC, FOOTER_MAGIC_SIZE))
return XZ_DATA_ERROR;
if (xz_crc32(s->temp.buf + 4, 6, 0) != get_le32(s->temp.buf))
return XZ_DATA_ERROR;
/*
* Validate Backward Size. Note that we never added the size of the
* Index CRC32 field to s->index.size, thus we use s->index.size / 4
* instead of s->index.size / 4 - 1.
*/
if ((s->index.size >> 2) != get_le32(s->temp.buf + 4))
return XZ_DATA_ERROR;
if (s->temp.buf[8] != 0 || s->temp.buf[9] != s->check_type)
return XZ_DATA_ERROR;
/*
* Use XZ_STREAM_END instead of XZ_OK to be more convenient
* for the caller.
*/
return XZ_STREAM_END;
}
/* Decode the Block Header and initialize the filter chain. */
static enum xz_ret dec_block_header(struct xz_dec *s)
{
enum xz_ret ret;
/*
* Validate the CRC32. We know that the temp buffer is at least
* eight bytes so this is safe.
*/
s->temp.size -= 4;
if (xz_crc32(s->temp.buf, s->temp.size, 0)
!= get_le32(s->temp.buf + s->temp.size))
return XZ_DATA_ERROR;
s->temp.pos = 2;
/*
* Catch unsupported Block Flags. We support only one or two filters
* in the chain, so we catch that with the same test.
*/
#ifdef XZ_DEC_BCJ
if (s->temp.buf[1] & 0x3E)
#else
if (s->temp.buf[1] & 0x3F)
#endif
return XZ_OPTIONS_ERROR;
/* Compressed Size */
if (s->temp.buf[1] & 0x40) {
if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size)
!= XZ_STREAM_END)
return XZ_DATA_ERROR;
s->block_header.compressed = s->vli;
} else {
s->block_header.compressed = VLI_UNKNOWN;
}
/* Uncompressed Size */
if (s->temp.buf[1] & 0x80) {
if (dec_vli(s, s->temp.buf, &s->temp.pos, s->temp.size)
!= XZ_STREAM_END)
return XZ_DATA_ERROR;
s->block_header.uncompressed = s->vli;
} else {
s->block_header.uncompressed = VLI_UNKNOWN;
}
#ifdef XZ_DEC_BCJ
/* If there are two filters, the first one must be a BCJ filter. */
s->bcj_active = s->temp.buf[1] & 0x01;
if (s->bcj_active) {
if (s->temp.size - s->temp.pos < 2)
return XZ_OPTIONS_ERROR;
ret = xz_dec_bcj_reset(s->bcj, s->temp.buf[s->temp.pos++]);
if (ret != XZ_OK)
return ret;
/*
* We don't support custom start offset,
* so Size of Properties must be zero.
*/
if (s->temp.buf[s->temp.pos++] != 0x00)
return XZ_OPTIONS_ERROR;
}
#endif
/* Valid Filter Flags always take at least two bytes. */
if (s->temp.size - s->temp.pos < 2)
return XZ_DATA_ERROR;
/* Filter ID = LZMA2 */
if (s->temp.buf[s->temp.pos++] != 0x21)
return XZ_OPTIONS_ERROR;
/* Size of Properties = 1-byte Filter Properties */
if (s->temp.buf[s->temp.pos++] != 0x01)
return XZ_OPTIONS_ERROR;
/* Filter Properties contains LZMA2 dictionary size. */
if (s->temp.size - s->temp.pos < 1)
return XZ_DATA_ERROR;
ret = xz_dec_lzma2_reset(s->lzma2, s->temp.buf[s->temp.pos++]);
if (ret != XZ_OK)
return ret;
/* The rest must be Header Padding. */
while (s->temp.pos < s->temp.size)
if (s->temp.buf[s->temp.pos++] != 0x00)
return XZ_OPTIONS_ERROR;
s->temp.pos = 0;
s->block.compressed = 0;
s->block.uncompressed = 0;
return XZ_OK;
}
static enum xz_ret dec_main(struct xz_dec *s, struct xz_buf *b)
{
enum xz_ret ret;
/*
* Store the start position for the case when we are in the middle
* of the Index field.
*/
s->in_start = b->in_pos;
while (true) {
switch (s->sequence) {
case SEQ_STREAM_HEADER:
/*
* Stream Header is copied to s->temp, and then
* decoded from there. This way if the caller
* gives us only little input at a time, we can
* still keep the Stream Header decoding code
* simple. Similar approach is used in many places
* in this file.
*/
if (!fill_temp(s, b))
return XZ_OK;
/*
* If dec_stream_header() returns
* XZ_UNSUPPORTED_CHECK, it is still possible
* to continue decoding if working in multi-call
* mode. Thus, update s->sequence before calling
* dec_stream_header().
*/
s->sequence = SEQ_BLOCK_START;
ret = dec_stream_header(s);
if (ret != XZ_OK)
return ret;
fallthrough;
case SEQ_BLOCK_START:
/* We need one byte of input to continue. */
if (b->in_pos == b->in_size)
return XZ_OK;
/* See if this is the beginning of the Index field. */
if (b->in[b->in_pos] == 0) {
s->in_start = b->in_pos++;
s->sequence = SEQ_INDEX;
break;
}
/*
* Calculate the size of the Block Header and
* prepare to decode it.
*/
s->block_header.size
= ((uint32_t)b->in[b->in_pos] + 1) * 4;
s->temp.size = s->block_header.size;
s->temp.pos = 0;
s->sequence = SEQ_BLOCK_HEADER;
fallthrough;
case SEQ_BLOCK_HEADER:
if (!fill_temp(s, b))
return XZ_OK;
ret = dec_block_header(s);
if (ret != XZ_OK)
return ret;
#ifdef XZ_USE_SHA256
if (s->check_type == XZ_CHECK_SHA256)
xz_sha256_reset(&s->sha256);
#endif
s->sequence = SEQ_BLOCK_UNCOMPRESS;
fallthrough;
case SEQ_BLOCK_UNCOMPRESS:
ret = dec_block(s, b);
if (ret != XZ_STREAM_END)
return ret;
s->sequence = SEQ_BLOCK_PADDING;
fallthrough;
case SEQ_BLOCK_PADDING:
/*
* Size of Compressed Data + Block Padding
* must be a multiple of four. We don't need
* s->block.compressed for anything else
* anymore, so we use it here to test the size
* of the Block Padding field.
*/
while (s->block.compressed & 3) {
if (b->in_pos == b->in_size)
return XZ_OK;
if (b->in[b->in_pos++] != 0)
return XZ_DATA_ERROR;
++s->block.compressed;
}
s->sequence = SEQ_BLOCK_CHECK;
fallthrough;
case SEQ_BLOCK_CHECK:
if (s->check_type == XZ_CHECK_CRC32) {
ret = crc_validate(s, b, 32);
if (ret != XZ_STREAM_END)
return ret;
}
else if (IS_CRC64(s->check_type)) {
ret = crc_validate(s, b, 64);
if (ret != XZ_STREAM_END)
return ret;
}
#ifdef XZ_USE_SHA256
else if (s->check_type == XZ_CHECK_SHA256) {
s->temp.size = 32;
if (!fill_temp(s, b))
return XZ_OK;
if (!xz_sha256_validate(s->temp.buf,
&s->sha256))
return XZ_DATA_ERROR;
s->pos = 0;
}
#endif
#ifdef XZ_DEC_ANY_CHECK
else if (!check_skip(s, b)) {
return XZ_OK;
}
#endif
s->sequence = SEQ_BLOCK_START;
break;
case SEQ_INDEX:
ret = dec_index(s, b);
if (ret != XZ_STREAM_END)
return ret;
s->sequence = SEQ_INDEX_PADDING;
fallthrough;
case SEQ_INDEX_PADDING:
while ((s->index.size + (b->in_pos - s->in_start))
& 3) {
if (b->in_pos == b->in_size) {
index_update(s, b);
return XZ_OK;
}
if (b->in[b->in_pos++] != 0)
return XZ_DATA_ERROR;
}
/* Finish the CRC32 value and Index size. */
index_update(s, b);
/* Compare the hashes to validate the Index field. */
if (!memeq(&s->block.hash, &s->index.hash,
sizeof(s->block.hash)))
return XZ_DATA_ERROR;
s->sequence = SEQ_INDEX_CRC32;
fallthrough;
case SEQ_INDEX_CRC32:
ret = crc_validate(s, b, 32);
if (ret != XZ_STREAM_END)
return ret;
s->temp.size = STREAM_HEADER_SIZE;
s->sequence = SEQ_STREAM_FOOTER;
fallthrough;
case SEQ_STREAM_FOOTER:
if (!fill_temp(s, b))
return XZ_OK;
return dec_stream_footer(s);
case SEQ_STREAM_PADDING:
/* Never reached, only silencing a warning */
break;
}
}
/* Never reached */
}
/*
* xz_dec_run() is a wrapper for dec_main() to handle some special cases in
* multi-call and single-call decoding.
*
* In multi-call mode, we must return XZ_BUF_ERROR when it seems clear that we
* are not going to make any progress anymore. This is to prevent the caller
* from calling us infinitely when the input file is truncated or otherwise
* corrupt. Since zlib-style API allows that the caller fills the input buffer
* only when the decoder doesn't produce any new output, we have to be careful
* to avoid returning XZ_BUF_ERROR too easily: XZ_BUF_ERROR is returned only
* after the second consecutive call to xz_dec_run() that makes no progress.
*
* In single-call mode, if we couldn't decode everything and no error
* occurred, either the input is truncated or the output buffer is too small.
* Since we know that the last input byte never produces any output, we know
* that if all the input was consumed and decoding wasn't finished, the file
* must be corrupt. Otherwise the output buffer has to be too small or the
* file is corrupt in a way that decoding it produces too big output.
*
* If single-call decoding fails, we reset b->in_pos and b->out_pos back to
* their original values. This is because with some filter chains there won't
* be any valid uncompressed data in the output buffer unless the decoding
* actually succeeds (that's the price to pay of using the output buffer as
* the workspace).
*/
XZ_EXTERN enum xz_ret xz_dec_run(struct xz_dec *s, struct xz_buf *b)
{
size_t in_start;
size_t out_start;
enum xz_ret ret;
if (DEC_IS_SINGLE(s->mode))
xz_dec_reset(s);
in_start = b->in_pos;
out_start = b->out_pos;
ret = dec_main(s, b);
if (DEC_IS_SINGLE(s->mode)) {
if (ret == XZ_OK)
ret = b->in_pos == b->in_size
? XZ_DATA_ERROR : XZ_BUF_ERROR;
if (ret != XZ_STREAM_END) {
b->in_pos = in_start;
b->out_pos = out_start;
}
} else if (ret == XZ_OK && in_start == b->in_pos
&& out_start == b->out_pos) {
if (s->allow_buf_error)
ret = XZ_BUF_ERROR;
s->allow_buf_error = true;
} else {
s->allow_buf_error = false;
}
return ret;
}
#ifdef XZ_DEC_CONCATENATED
XZ_EXTERN enum xz_ret xz_dec_catrun(struct xz_dec *s, struct xz_buf *b,
int finish)
{
enum xz_ret ret;
if (DEC_IS_SINGLE(s->mode)) {
xz_dec_reset(s);
finish = true;
}
while (true) {
if (s->sequence == SEQ_STREAM_PADDING) {
/*
* Skip Stream Padding. Its size must be a multiple
* of four bytes which is tracked with s->pos.
*/
while (true) {
if (b->in_pos == b->in_size) {
/*
* Note that if we are repeatedly
* given no input and finish is false,
* we will keep returning XZ_OK even
* though no progress is being made.
* The lack of XZ_BUF_ERROR support
* isn't a problem here because a
* reasonable caller will eventually
* provide more input or set finish
* to true.
*/
if (!finish)
return XZ_OK;
if (s->pos != 0)
return XZ_DATA_ERROR;
return XZ_STREAM_END;
}
if (b->in[b->in_pos] != 0x00) {
if (s->pos != 0)
return XZ_DATA_ERROR;
break;
}
++b->in_pos;
s->pos = (s->pos + 1) & 3;
}
/*
* More input remains. It should be a new Stream.
*
* In single-call mode xz_dec_run() will always call
* xz_dec_reset(). Thus, we need to do it here only
* in multi-call mode.
*/
if (DEC_IS_MULTI(s->mode))
xz_dec_reset(s);
}
ret = xz_dec_run(s, b);
if (ret != XZ_STREAM_END)
break;
s->sequence = SEQ_STREAM_PADDING;
}
return ret;
}
#endif
XZ_EXTERN struct xz_dec *xz_dec_init(enum xz_mode mode, uint32_t dict_max)
{
struct xz_dec *s = kmalloc(sizeof(*s), GFP_KERNEL);
if (s == NULL)
return NULL;
s->mode = mode;
#ifdef XZ_DEC_BCJ
s->bcj = xz_dec_bcj_create(DEC_IS_SINGLE(mode));
if (s->bcj == NULL)
goto error_bcj;
#endif
s->lzma2 = xz_dec_lzma2_create(mode, dict_max);
if (s->lzma2 == NULL)
goto error_lzma2;
xz_dec_reset(s);
return s;
error_lzma2:
#ifdef XZ_DEC_BCJ
xz_dec_bcj_end(s->bcj);
error_bcj:
#endif
kfree(s);
return NULL;
}
XZ_EXTERN void xz_dec_reset(struct xz_dec *s)
{
s->sequence = SEQ_STREAM_HEADER;
s->allow_buf_error = false;
s->pos = 0;
s->crc = 0;
memzero(&s->block, sizeof(s->block));
memzero(&s->index, sizeof(s->index));
s->temp.pos = 0;
s->temp.size = STREAM_HEADER_SIZE;
}
XZ_EXTERN void xz_dec_end(struct xz_dec *s)
{
if (s != NULL) {
xz_dec_lzma2_end(s->lzma2);
#ifdef XZ_DEC_BCJ
xz_dec_bcj_end(s->bcj);
#endif
kfree(s);
}
}

View File

@@ -0,0 +1,203 @@
/* SPDX-License-Identifier: 0BSD */
/*
* LZMA2 definitions
*
* Authors: Lasse Collin <lasse.collin@tukaani.org>
* Igor Pavlov <https://7-zip.org/>
*/
#ifndef XZ_LZMA2_H
#define XZ_LZMA2_H
/* Range coder constants */
#define RC_SHIFT_BITS 8
#define RC_TOP_BITS 24
#define RC_TOP_VALUE (1 << RC_TOP_BITS)
#define RC_BIT_MODEL_TOTAL_BITS 11
#define RC_BIT_MODEL_TOTAL (1 << RC_BIT_MODEL_TOTAL_BITS)
#define RC_MOVE_BITS 5
/*
* Maximum number of position states. A position state is the lowest pb
* number of bits of the current uncompressed offset. In some places there
* are different sets of probabilities for different position states.
*/
#define POS_STATES_MAX (1 << 4)
/*
* This enum is used to track which LZMA symbols have occurred most recently
* and in which order. This information is used to predict the next symbol.
*
* Symbols:
* - Literal: One 8-bit byte
* - Match: Repeat a chunk of data at some distance
* - Long repeat: Multi-byte match at a recently seen distance
* - Short repeat: One-byte repeat at a recently seen distance
*
* The symbol names are in from STATE_oldest_older_previous. REP means
* either short or long repeated match, and NONLIT means any non-literal.
*/
enum lzma_state {
STATE_LIT_LIT,
STATE_MATCH_LIT_LIT,
STATE_REP_LIT_LIT,
STATE_SHORTREP_LIT_LIT,
STATE_MATCH_LIT,
STATE_REP_LIT,
STATE_SHORTREP_LIT,
STATE_LIT_MATCH,
STATE_LIT_LONGREP,
STATE_LIT_SHORTREP,
STATE_NONLIT_MATCH,
STATE_NONLIT_REP
};
/* Total number of states */
#define STATES 12
/* The lowest 7 states indicate that the previous state was a literal. */
#define LIT_STATES 7
/* Indicate that the latest symbol was a literal. */
static inline void lzma_state_literal(enum lzma_state *state)
{
if (*state <= STATE_SHORTREP_LIT_LIT)
*state = STATE_LIT_LIT;
else if (*state <= STATE_LIT_SHORTREP)
*state -= 3;
else
*state -= 6;
}
/* Indicate that the latest symbol was a match. */
static inline void lzma_state_match(enum lzma_state *state)
{
*state = *state < LIT_STATES ? STATE_LIT_MATCH : STATE_NONLIT_MATCH;
}
/* Indicate that the latest state was a long repeated match. */
static inline void lzma_state_long_rep(enum lzma_state *state)
{
*state = *state < LIT_STATES ? STATE_LIT_LONGREP : STATE_NONLIT_REP;
}
/* Indicate that the latest symbol was a short match. */
static inline void lzma_state_short_rep(enum lzma_state *state)
{
*state = *state < LIT_STATES ? STATE_LIT_SHORTREP : STATE_NONLIT_REP;
}
/* Test if the previous symbol was a literal. */
static inline bool lzma_state_is_literal(enum lzma_state state)
{
return state < LIT_STATES;
}
/* Each literal coder is divided in three sections:
* - 0x001-0x0FF: Without match byte
* - 0x101-0x1FF: With match byte; match bit is 0
* - 0x201-0x2FF: With match byte; match bit is 1
*
* Match byte is used when the previous LZMA symbol was something else than
* a literal (that is, it was some kind of match).
*/
#define LITERAL_CODER_SIZE 0x300
/* Maximum number of literal coders */
#define LITERAL_CODERS_MAX (1 << 4)
/* Minimum length of a match is two bytes. */
#define MATCH_LEN_MIN 2
/* Match length is encoded with 4, 5, or 10 bits.
*
* Length Bits
* 2-9 4 = Choice=0 + 3 bits
* 10-17 5 = Choice=1 + Choice2=0 + 3 bits
* 18-273 10 = Choice=1 + Choice2=1 + 8 bits
*/
#define LEN_LOW_BITS 3
#define LEN_LOW_SYMBOLS (1 << LEN_LOW_BITS)
#define LEN_MID_BITS 3
#define LEN_MID_SYMBOLS (1 << LEN_MID_BITS)
#define LEN_HIGH_BITS 8
#define LEN_HIGH_SYMBOLS (1 << LEN_HIGH_BITS)
#define LEN_SYMBOLS (LEN_LOW_SYMBOLS + LEN_MID_SYMBOLS + LEN_HIGH_SYMBOLS)
/*
* Maximum length of a match is 273 which is a result of the encoding
* described above.
*/
#define MATCH_LEN_MAX (MATCH_LEN_MIN + LEN_SYMBOLS - 1)
/*
* Different sets of probabilities are used for match distances that have
* very short match length: Lengths of 2, 3, and 4 bytes have a separate
* set of probabilities for each length. The matches with longer length
* use a shared set of probabilities.
*/
#define DIST_STATES 4
/*
* Get the index of the appropriate probability array for decoding
* the distance slot.
*/
static inline uint32_t lzma_get_dist_state(uint32_t len)
{
return len < DIST_STATES + MATCH_LEN_MIN
? len - MATCH_LEN_MIN : DIST_STATES - 1;
}
/*
* The highest two bits of a 32-bit match distance are encoded using six bits.
* This six-bit value is called a distance slot. This way encoding a 32-bit
* value takes 6-36 bits, larger values taking more bits.
*/
#define DIST_SLOT_BITS 6
#define DIST_SLOTS (1 << DIST_SLOT_BITS)
/* Match distances up to 127 are fully encoded using probabilities. Since
* the highest two bits (distance slot) are always encoded using six bits,
* the distances 0-3 don't need any additional bits to encode, since the
* distance slot itself is the same as the actual distance. DIST_MODEL_START
* indicates the first distance slot where at least one additional bit is
* needed.
*/
#define DIST_MODEL_START 4
/*
* Match distances greater than 127 are encoded in three pieces:
* - distance slot: the highest two bits
* - direct bits: 2-26 bits below the highest two bits
* - alignment bits: four lowest bits
*
* Direct bits don't use any probabilities.
*
* The distance slot value of 14 is for distances 128-191.
*/
#define DIST_MODEL_END 14
/* Distance slots that indicate a distance <= 127. */
#define FULL_DISTANCES_BITS (DIST_MODEL_END / 2)
#define FULL_DISTANCES (1 << FULL_DISTANCES_BITS)
/*
* For match distances greater than 127, only the highest two bits and the
* lowest four bits (alignment) is encoded using probabilities.
*/
#define ALIGN_BITS 4
#define ALIGN_SIZE (1 << ALIGN_BITS)
#define ALIGN_MASK (ALIGN_SIZE - 1)
/* Total number of all probability variables */
#define PROBS_TOTAL (1846 + LITERAL_CODERS_MAX * LITERAL_CODER_SIZE)
/*
* LZMA remembers the four most recent match distances. Reusing these
* distances tends to take less space than re-encoding the actual
* distance value.
*/
#define REPS 4
#endif

View File

@@ -0,0 +1,189 @@
/* SPDX-License-Identifier: 0BSD */
/*
* Private includes and definitions
*
* Author: Lasse Collin <lasse.collin@tukaani.org>
*/
#ifndef XZ_PRIVATE_H
#define XZ_PRIVATE_H
#ifdef __KERNEL__
# include <linux/xz.h>
# include <linux/kernel.h>
# include <linux/unaligned.h>
/* XZ_PREBOOT may be defined only via decompress_unxz.c. */
# ifndef XZ_PREBOOT
# include <linux/slab.h>
# include <linux/vmalloc.h>
# include <linux/string.h>
# ifdef CONFIG_XZ_DEC_X86
# define XZ_DEC_X86
# endif
# ifdef CONFIG_XZ_DEC_POWERPC
# define XZ_DEC_POWERPC
# endif
# ifdef CONFIG_XZ_DEC_IA64
# define XZ_DEC_IA64
# endif
# ifdef CONFIG_XZ_DEC_ARM
# define XZ_DEC_ARM
# endif
# ifdef CONFIG_XZ_DEC_ARMTHUMB
# define XZ_DEC_ARMTHUMB
# endif
# ifdef CONFIG_XZ_DEC_SPARC
# define XZ_DEC_SPARC
# endif
# ifdef CONFIG_XZ_DEC_ARM64
# define XZ_DEC_ARM64
# endif
# ifdef CONFIG_XZ_DEC_RISCV
# define XZ_DEC_RISCV
# endif
# ifdef CONFIG_XZ_DEC_MICROLZMA
# define XZ_DEC_MICROLZMA
# endif
# define memeq(a, b, size) (memcmp(a, b, size) == 0)
# define memzero(buf, size) memset(buf, 0, size)
# endif
# define get_le32(p) le32_to_cpup((const uint32_t *)(p))
#else
/*
* For userspace builds, use a separate header to define the required
* macros and functions. This makes it easier to adapt the code into
* different environments and avoids clutter in the Linux kernel tree.
*/
# include "xz_config.h"
#endif
/* If no specific decoding mode is requested, enable support for all modes. */
#if !defined(XZ_DEC_SINGLE) && !defined(XZ_DEC_PREALLOC) \
&& !defined(XZ_DEC_DYNALLOC)
# define XZ_DEC_SINGLE
# define XZ_DEC_PREALLOC
# define XZ_DEC_DYNALLOC
#endif
/*
* The DEC_IS_foo(mode) macros are used in "if" statements. If only some
* of the supported modes are enabled, these macros will evaluate to true or
* false at compile time and thus allow the compiler to omit unneeded code.
*/
#ifdef XZ_DEC_SINGLE
# define DEC_IS_SINGLE(mode) ((mode) == XZ_SINGLE)
#else
# define DEC_IS_SINGLE(mode) (false)
#endif
#ifdef XZ_DEC_PREALLOC
# define DEC_IS_PREALLOC(mode) ((mode) == XZ_PREALLOC)
#else
# define DEC_IS_PREALLOC(mode) (false)
#endif
#ifdef XZ_DEC_DYNALLOC
# define DEC_IS_DYNALLOC(mode) ((mode) == XZ_DYNALLOC)
#else
# define DEC_IS_DYNALLOC(mode) (false)
#endif
#if !defined(XZ_DEC_SINGLE)
# define DEC_IS_MULTI(mode) (true)
#elif defined(XZ_DEC_PREALLOC) || defined(XZ_DEC_DYNALLOC)
# define DEC_IS_MULTI(mode) ((mode) != XZ_SINGLE)
#else
# define DEC_IS_MULTI(mode) (false)
#endif
/*
* If any of the BCJ filter decoders are wanted, define XZ_DEC_BCJ.
* XZ_DEC_BCJ is used to enable generic support for BCJ decoders.
*/
#ifndef XZ_DEC_BCJ
# if defined(XZ_DEC_X86) || defined(XZ_DEC_POWERPC) \
|| defined(XZ_DEC_IA64) \
|| defined(XZ_DEC_ARM) || defined(XZ_DEC_ARMTHUMB) \
|| defined(XZ_DEC_SPARC) || defined(XZ_DEC_ARM64) \
|| defined(XZ_DEC_RISCV)
# define XZ_DEC_BCJ
# endif
#endif
struct xz_sha256 {
/* Buffered input data */
uint8_t data[64];
/* Internal state and the final hash value */
uint32_t state[8];
/* Size of the input data */
uint64_t size;
};
/* Reset the SHA-256 state to prepare for a new calculation. */
XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s);
/* Update the SHA-256 state with new data. */
XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size,
struct xz_sha256 *s);
/*
* Finish the SHA-256 calculation. Compare the result with the first 32 bytes
* from buf. Return true if the values are equal and false if they aren't.
*/
XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s);
/*
* Allocate memory for LZMA2 decoder. xz_dec_lzma2_reset() must be used
* before calling xz_dec_lzma2_run().
*/
XZ_EXTERN struct xz_dec_lzma2 *xz_dec_lzma2_create(enum xz_mode mode,
uint32_t dict_max);
/*
* Decode the LZMA2 properties (one byte) and reset the decoder. Return
* XZ_OK on success, XZ_MEMLIMIT_ERROR if the preallocated dictionary is not
* big enough, and XZ_OPTIONS_ERROR if props indicates something that this
* decoder doesn't support.
*/
XZ_EXTERN enum xz_ret xz_dec_lzma2_reset(struct xz_dec_lzma2 *s,
uint8_t props);
/* Decode raw LZMA2 stream from b->in to b->out. */
XZ_EXTERN enum xz_ret xz_dec_lzma2_run(struct xz_dec_lzma2 *s,
struct xz_buf *b);
/* Free the memory allocated for the LZMA2 decoder. */
XZ_EXTERN void xz_dec_lzma2_end(struct xz_dec_lzma2 *s);
#ifdef XZ_DEC_BCJ
/*
* Allocate memory for BCJ decoders. xz_dec_bcj_reset() must be used before
* calling xz_dec_bcj_run().
*/
XZ_EXTERN struct xz_dec_bcj *xz_dec_bcj_create(bool single_call);
/*
* Decode the Filter ID of a BCJ filter. This implementation doesn't
* support custom start offsets, so no decoding of Filter Properties
* is needed. Returns XZ_OK if the given Filter ID is supported.
* Otherwise XZ_OPTIONS_ERROR is returned.
*/
XZ_EXTERN enum xz_ret xz_dec_bcj_reset(struct xz_dec_bcj *s, uint8_t id);
/*
* Decode raw BCJ + LZMA2 stream. This must be used only if there actually is
* a BCJ filter in the chain. If the chain has only LZMA2, xz_dec_lzma2_run()
* must be called directly.
*/
XZ_EXTERN enum xz_ret xz_dec_bcj_run(struct xz_dec_bcj *s,
struct xz_dec_lzma2 *lzma2,
struct xz_buf *b);
/* Free the memory allocated for the BCJ filters. */
#define xz_dec_bcj_end(s) kfree(s)
#endif
#endif

View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: 0BSD
/*
* SHA-256
*
* This is based on the XZ Utils version which is based public domain code
* from Crypto++ Library 5.5.1 released in 2007: https://www.cryptopp.com/
*
* Authors: Wei Dai
* Lasse Collin <lasse.collin@tukaani.org>
*/
#include "xz_private.h"
static inline uint32_t
rotr_32(uint32_t num, unsigned amount)
{
return (num >> amount) | (num << (32 - amount));
}
#define blk0(i) (W[i] = get_be32(&data[4 * i]))
#define blk2(i) (W[i & 15] += s1(W[(i - 2) & 15]) + W[(i - 7) & 15] \
+ s0(W[(i - 15) & 15]))
#define Ch(x, y, z) (z ^ (x & (y ^ z)))
#define Maj(x, y, z) ((x & (y ^ z)) + (y & z))
#define a(i) T[(0 - i) & 7]
#define b(i) T[(1 - i) & 7]
#define c(i) T[(2 - i) & 7]
#define d(i) T[(3 - i) & 7]
#define e(i) T[(4 - i) & 7]
#define f(i) T[(5 - i) & 7]
#define g(i) T[(6 - i) & 7]
#define h(i) T[(7 - i) & 7]
#define R(i, j, blk) \
h(i) += S1(e(i)) + Ch(e(i), f(i), g(i)) + SHA256_K[i + j] + blk; \
d(i) += h(i); \
h(i) += S0(a(i)) + Maj(a(i), b(i), c(i))
#define R0(i) R(i, 0, blk0(i))
#define R2(i) R(i, j, blk2(i))
#define S0(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 9), 11), 2)
#define S1(x) rotr_32(x ^ rotr_32(x ^ rotr_32(x, 14), 5), 6)
#define s0(x) (rotr_32(x ^ rotr_32(x, 11), 7) ^ (x >> 3))
#define s1(x) (rotr_32(x ^ rotr_32(x, 2), 17) ^ (x >> 10))
static const uint32_t SHA256_K[64] = {
0x428A2F98, 0x71374491, 0xB5C0FBCF, 0xE9B5DBA5,
0x3956C25B, 0x59F111F1, 0x923F82A4, 0xAB1C5ED5,
0xD807AA98, 0x12835B01, 0x243185BE, 0x550C7DC3,
0x72BE5D74, 0x80DEB1FE, 0x9BDC06A7, 0xC19BF174,
0xE49B69C1, 0xEFBE4786, 0x0FC19DC6, 0x240CA1CC,
0x2DE92C6F, 0x4A7484AA, 0x5CB0A9DC, 0x76F988DA,
0x983E5152, 0xA831C66D, 0xB00327C8, 0xBF597FC7,
0xC6E00BF3, 0xD5A79147, 0x06CA6351, 0x14292967,
0x27B70A85, 0x2E1B2138, 0x4D2C6DFC, 0x53380D13,
0x650A7354, 0x766A0ABB, 0x81C2C92E, 0x92722C85,
0xA2BFE8A1, 0xA81A664B, 0xC24B8B70, 0xC76C51A3,
0xD192E819, 0xD6990624, 0xF40E3585, 0x106AA070,
0x19A4C116, 0x1E376C08, 0x2748774C, 0x34B0BCB5,
0x391C0CB3, 0x4ED8AA4A, 0x5B9CCA4F, 0x682E6FF3,
0x748F82EE, 0x78A5636F, 0x84C87814, 0x8CC70208,
0x90BEFFFA, 0xA4506CEB, 0xBEF9A3F7, 0xC67178F2
};
static void
transform(uint32_t state[8], const uint8_t data[64])
{
uint32_t W[16];
uint32_t T[8];
unsigned int j;
/* Copy state[] to working vars. */
memcpy(T, state, sizeof(T));
/* The first 16 operations unrolled */
R0( 0); R0( 1); R0( 2); R0( 3);
R0( 4); R0( 5); R0( 6); R0( 7);
R0( 8); R0( 9); R0(10); R0(11);
R0(12); R0(13); R0(14); R0(15);
/* The remaining 48 operations partially unrolled */
for (j = 16; j < 64; j += 16) {
R2( 0); R2( 1); R2( 2); R2( 3);
R2( 4); R2( 5); R2( 6); R2( 7);
R2( 8); R2( 9); R2(10); R2(11);
R2(12); R2(13); R2(14); R2(15);
}
/* Add the working vars back into state[]. */
state[0] += a(0);
state[1] += b(0);
state[2] += c(0);
state[3] += d(0);
state[4] += e(0);
state[5] += f(0);
state[6] += g(0);
state[7] += h(0);
}
XZ_EXTERN void xz_sha256_reset(struct xz_sha256 *s)
{
static const uint32_t initial_state[8] = {
0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19
};
memcpy(s->state, initial_state, sizeof(initial_state));
s->size = 0;
}
XZ_EXTERN void xz_sha256_update(const uint8_t *buf, size_t size,
struct xz_sha256 *s)
{
size_t copy_start;
size_t copy_size;
/*
* Copy the input data into a properly aligned temporary buffer.
* This way we can be called with arbitrarily sized buffers
* (no need to be a multiple of 64 bytes).
*
* Full 64-byte chunks could be processed directly from buf with
* unaligned access. It seemed to make very little difference in
* speed on x86-64 though. Thus it was omitted.
*/
while (size > 0) {
copy_start = s->size & 0x3F;
copy_size = 64 - copy_start;
if (copy_size > size)
copy_size = size;
memcpy(s->data + copy_start, buf, copy_size);
buf += copy_size;
size -= copy_size;
s->size += copy_size;
if ((s->size & 0x3F) == 0)
transform(s->state, s->data);
}
}
XZ_EXTERN bool xz_sha256_validate(const uint8_t *buf, struct xz_sha256 *s)
{
/*
* Add padding as described in RFC 3174 (it describes SHA-1 but
* the same padding style is used for SHA-256 too).
*/
size_t i = s->size & 0x3F;
s->data[i++] = 0x80;
while (i != 64 - 8) {
if (i == 64) {
transform(s->state, s->data);
i = 0;
}
s->data[i++] = 0x00;
}
/* Convert the message size from bytes to bits. */
s->size *= 8;
/*
* Store the message size in big endian byte order and
* calculate the final hash value.
*/
for (i = 0; i < 8; ++i)
s->data[64 - 8 + i] = (uint8_t)(s->size >> ((7 - i) * 8));
transform(s->state, s->data);
/* Compare if the hash value matches the first 32 bytes in buf. */
for (i = 0; i < 8; ++i)
if (get_unaligned_be32(buf + 4 * i) != s->state[i])
return false;
return true;
}

View File

@@ -0,0 +1,61 @@
/* SPDX-License-Identifier: 0BSD */
/*
* Definitions for handling the .xz file format
*
* Author: Lasse Collin <lasse.collin@tukaani.org>
*/
#ifndef XZ_STREAM_H
#define XZ_STREAM_H
#if defined(__KERNEL__) && !XZ_INTERNAL_CRC32
# include <linux/crc32.h>
# undef crc32
# define xz_crc32(buf, size, crc) \
(~crc32_le(~(uint32_t)(crc), buf, size))
#endif
/*
* See the .xz file format specification at
* https://tukaani.org/xz/xz-file-format.txt
* to understand the container format.
*/
#define STREAM_HEADER_SIZE 12
#define HEADER_MAGIC "\3757zXZ"
#define HEADER_MAGIC_SIZE 6
#define FOOTER_MAGIC "YZ"
#define FOOTER_MAGIC_SIZE 2
/*
* Variable-length integer can hold a 63-bit unsigned integer or a special
* value indicating that the value is unknown.
*
* Experimental: vli_type can be defined to uint32_t to save a few bytes
* in code size (no effect on speed). Doing so limits the uncompressed and
* compressed size of the file to less than 256 MiB and may also weaken
* error detection slightly.
*/
typedef uint64_t vli_type;
#define VLI_MAX ((vli_type)-1 / 2)
#define VLI_UNKNOWN ((vli_type)-1)
/* Maximum encoded size of a VLI */
#define VLI_BYTES_MAX (sizeof(vli_type) * 8 / 7)
/* Integrity Check types */
enum xz_check {
XZ_CHECK_NONE = 0,
XZ_CHECK_CRC32 = 1,
XZ_CHECK_CRC64 = 4,
XZ_CHECK_SHA256 = 10
};
/* Maximum possible Check ID */
#define XZ_CHECK_MAX 15
#endif

View File

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

View File

@@ -20,20 +20,22 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -52,6 +54,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -73,7 +76,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -88,7 +90,6 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
@@ -103,6 +104,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -110,46 +112,60 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.play.core.review.ReviewManagerFactory
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.screens.CameraControlScreen import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.screens.VersionScreen import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.isSupported
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
init { init {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
System.loadLibrary("l2c_fcr_hook") System.loadLibrary("l2c_fcr_hook")
} }
} }
}
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@@ -159,8 +175,6 @@ class MainActivity : ComponentActivity() {
Main() Main()
} }
} }
handleIncomingIntent(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -195,82 +209,108 @@ class MainActivity : ComponentActivity() {
} }
super.onStop() super.onStop()
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
Log.d("LibrePods", "Parameter: $param = $value")
}
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit {putString("IRK", irkBase64)}
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
private fun hexStringToByteArray(hex: String): ByteArray {
val result = ByteArray(16)
for (i in 0 until 16) {
val hexByte = hex.substring(i * 2, i * 2 + 2)
result[i] = hexByte.toInt(16).toByte()
}
return result
}
} }
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun Main() { fun Main() {
val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
Column(
modifier = Modifier,
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.not_supported),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = textColor,
fontSize = 28.sp,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Light,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 14.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
DeviceInfoCard()
AppInfoCard()
}
Spacer(modifier = Modifier.height(48.dp))
}
}
return
}
val isConnected = remember { mutableStateOf(false) }
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } val overlaySkipped = remember {
mutableStateOf(
context.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("overlay_permission_skipped", false)
)
}
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
@@ -297,20 +337,55 @@ fun Main() {
val permissionState = rememberMultiplePermissionsState( val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions permissions = allPermissions
) )
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel = remember(airPodsService.value) {
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
controlRepo = ControlCommandRepository(service.aacpManager),
appContext = context.applicationContext
)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context) canDrawOverlays = Settings.canDrawOverlays(context)
} }
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
LaunchedEffect(Unit) {
if (BuildConfig.PLAY_BUILD) {
val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val oneDay = 24 * 60 * 60 * 1000L
if (
firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) {
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
sharedPreferences.edit {
putBoolean("review_prompted", true)
}
}
}
}
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
) { ) {
val backButtonBackdrop = rememberLayerBackdrop() val backButtonBackdrop = rememberLayerBackdrop()
Box( Box(
@@ -321,96 +396,87 @@ fun Main() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (hookAvailable) "settings" else "onboarding", startDestination = "settings",
enterTransition = { enterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { it }, initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeIn(animationSpec = tween(durationMillis = 300))
}, },
exitTransition = { exitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { -it/4 }, targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}, },
popEnterTransition = { popEnterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -it / 4 }, initialOffsetX = { -it / 4 },
animationSpec = tween(durationMillis = 300) animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300)) )
}, },
popExitTransition = { popExitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { it }, targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
) )
} }) {
composable("settings") {
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
} }
composable("debug") { composable("debug") {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
composable("long_press/{bud}") { navBackStackEntry -> composable("long_press/{bud}") { navBackStackEntry ->
LongPress( if (airPodsViewModel != null) LongPress(
navController = navController, viewModel = airPodsViewModel,
name = navBackStackEntry.arguments?.getString("bud")!! name = navBackStackEntry.arguments?.getString("bud")!!,
navController = navController
) )
} }
composable("rename") { composable("rename") {
RenameScreen(navController) if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
} }
composable("app_settings") { composable("app_settings") {
AppSettingsScreen(navController) val appSettingsViewModel: AppSettingsViewModel = viewModel()
AppSettingsScreen(navController, appSettingsViewModel)
} }
composable("troubleshooting") { composable("troubleshooting") {
TroubleshootingScreen(navController) TroubleshootingScreen(navController)
} }
composable("head_tracking") { composable("head_tracking") {
HeadTrackingScreen() if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
}
composable("onboarding") {
Onboarding(navController, context)
} }
composable("accessibility") { composable("accessibility") {
AccessibilitySettingsScreen(navController) if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
} }
composable("transparency_customization") { composable("transparency_customization") {
TransparencySettingsScreen(navController) if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
} }
composable("hearing_aid") { composable("hearing_aid") {
HearingAidScreen(navController) if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
} }
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController) if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
} }
composable("adaptive_strength") { composable("adaptive_strength") {
AdaptiveStrengthScreen(navController) if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
} }
composable("camera_control") { composable("camera_control") {
CameraControlScreen(navController) if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
} }
composable("open_source_licenses") { composable("open_source_licenses") {
OpenSourceLicensesScreen(navController) OpenSourceLicensesScreen(navController)
} }
composable("update_hearing_test") { composable("update_hearing_test") {
UpdateHearingTestScreen(navController) if (airPodsViewModel != null) UpdateHearingTestScreen()
} }
composable("version_info") { composable("version_info") {
VersionScreen(navController) if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
HearingProtectionScreen(navController) if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
} }
} }
} }
@@ -419,36 +485,48 @@ fun Main() {
LaunchedEffect(navController) { LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ -> navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" && destination.route != "onboarding" showBackButton.value =
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}") destination.route != "settings" // && destination.route != "onboarding"
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = showBackButton.value, visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()), enter = fadeIn(animationSpec = tween()) + scaleIn(
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)), initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut(animationSpec = tween()) + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding( .padding(
start = 8.dp, start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
) )
) { ) {
StyledIconButton( StyledIconButton(
onClick = { navController.popBackStack() }, onClick = { navController.popBackStack() },
icon = "􀯶", icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop backdrop = backButtonBackdrop
) )
} }
} }
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = remember { serviceConnection = remember {
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService() airPodsService.value = binder.getService()
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
@@ -457,17 +535,31 @@ fun Main() {
} }
} }
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
if (airPodsService.value?.isConnectedLocally == true) { if (airPodsService.value?.isConnected() == true) {
isConnected.value = true isConnected.value = true
} }
} else { } else {
PermissionsScreen( PermissionsScreen(
permissionState = permissionState, permissionState = permissionState,
canDrawOverlays = canDrawOverlays, canDrawOverlays = canDrawOverlays,
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) } onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
) }
}
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)
}
} }
} }
@@ -490,13 +582,9 @@ fun PermissionsScreen(
val infiniteTransition = rememberInfiniteTransition(label = "pulse") val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat( val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f, initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
targetValue = 1.05f, animation = tween(1000), repeatMode = RepeatMode.Reverse
animationSpec = infiniteRepeatable( ), label = "pulse scale"
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
) )
Column( Column(
@@ -504,18 +592,15 @@ fun PermissionsScreen(
.fillMaxSize() .fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp) .padding(16.dp)
.verticalScroll(scrollState), .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp), .height(180.dp), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "\uDBC2\uDEB7", text = "\uDBC2\uDEB7", style = TextStyle(
style = TextStyle(
fontSize = 48.sp, fontSize = 48.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -551,29 +636,25 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Permission Required", text = "Permission Required", style = TextStyle(
style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor, color = textColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.permissions_required), text = stringResource(R.string.permissions_required), style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f), color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -746,8 +827,7 @@ fun PermissionCard(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f alpha = 0.15f
) )
), ), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -763,8 +843,7 @@ fun PermissionCard(
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = title, text = title, style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -773,8 +852,7 @@ fun PermissionCard(
) )
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -791,11 +869,8 @@ fun PermissionCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (isGranted) "" else "!", text = if (isGranted) "" else "!", style = TextStyle(
style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
) )
) )
} }

View File

@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.billing
object BillingManager {
lateinit var provider: BillingProvider
}

View File

@@ -0,0 +1,30 @@
/*
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()
}

View File

@@ -0,0 +1,33 @@
/*
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.content.Context
import me.kavishdevar.librepods.BuildConfig
object BillingProviderFactory {
fun create(context: Context): BillingProvider {
return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context)
} else {
FOSSBillingProvider(context)
}
}
}

View File

@@ -0,0 +1,77 @@
/*
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) }
}
}

View File

@@ -0,0 +1,206 @@
/*
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()
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.util.Log import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -55,13 +55,13 @@ class AACPManager {
const val TIPI_3: Byte = 0x0C // Don't know this one const val TIPI_3: Byte = 0x0C // Don't know this one
const val SMART_ROUTING_RESP: Byte = 0x11 const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14 const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
} }
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus( data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers, val identifier: ControlCommandIdentifiers, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -84,43 +84,33 @@ class AACPManager {
// @Suppress("unused") // @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) { enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01), MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
BUTTON_SEND_MODE(0x05), 0x15
VOICE_TRIGGER(0x12), ),
SINGLE_CLICK_MODE(0x14), CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
DOUBLE_CLICK_MODE(0x15), 0x1A
CLICK_HOLD_MODE(0x16), ),
DOUBLE_CLICK_INTERVAL(0x17), ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
CLICK_HOLD_INTERVAL(0x18), 0x1E
LISTENING_MODE_CONFIGS(0x1A), ),
ONE_BUD_ANC_MODE(0x1B), CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
CROWN_ROTATION_DIRECTION(0x1C), 0x25
LISTENING_MODE(0x0D), ),
AUTO_ANSWER_MODE(0x1E), ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
CHIME_VOLUME(0x1F), 0x28
VOLUME_SWIPE_INTERVAL(0x23), ),
CALL_MANAGEMENT_CONFIG(0x24), SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
VOLUME_SWIPE_MODE(0x25), 0x30
ADAPTIVE_VOLUME_CONFIG(0x26), ),
SOFTWARE_MUTE_CONFIG(0x27), IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
CONVERSATION_DETECT_CONFIG(0x28), 0x34
SSL(0x29), ),
HEARING_AID(0x2C), STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
AUTO_ANC_STRENGTH(0x2E), EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
HPS_GAIN_SWIPE(0x2F), 0x37
HRM_STATE(0x30), ),
IN_CASE_TONE_CONFIG(0x31), PPE_CAP_LEVEL_CONFIG(0x38),
SIRI_MULTITONE_CONFIG(0x32), DYNAMIC_END_OF_CHARGE(0x3B);
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39),
SLEEP_DETECTION_CONFIG(0x35),
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06),
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38);
companion object { companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? = fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -129,59 +119,44 @@ class AACPManager {
} }
enum class ProximityKeyType(val value: Byte) { enum class ProximityKeyType(val value: Byte) {
IRK(0x01), IRK(0x01), ENC_KEY(0x04);
ENC_KEY(0x04);
companion object { companion object {
fun fromByte(byte: Byte): ProximityKeyType = fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
ProximityKeyType.entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
} }
} }
enum class StemPressType(val value: Byte) { enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05), SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object { companion object {
fun fromByte(byte: Byte): StemPressType? = fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class StemPressBudType(val value: Byte) { enum class StemPressBudType(val value: Byte) {
LEFT(0x01), LEFT(0x01), RIGHT(0x02);
RIGHT(0x02);
companion object { companion object {
fun fromByte(byte: Byte): StemPressBudType? = fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class AudioSourceType(val value: Byte) { enum class AudioSourceType(val value: Byte) {
NONE(0x00), NONE(0x00), CALL(0x01), MEDIA(0x02);
CALL(0x01),
MEDIA(0x02);
companion object { companion object {
fun fromByte(byte: Byte): AudioSourceType? = fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
data class AudioSource( data class AudioSource(
val mac: String, val mac: String, val type: AudioSourceType
val type: AudioSourceType
) )
data class ConnectedDevice( data class ConnectedDevice(
val mac: String, val mac: String, val info1: Byte, val info2: Byte, var type: String?
val info1: Byte,
val info2: Byte,
var type: String?
) )
data class AirPodsInformation( data class AirPodsInformation(
@@ -216,7 +191,7 @@ class AACPManager {
var audioSource: AudioSource? = null var audioSource: AudioSource? = null
private set private set
var eqData = FloatArray(8) { 0.0f } var eqData = FloatArray(8)
private set private set
var eqOnPhone: Boolean = false var eqOnPhone: Boolean = false
@@ -230,14 +205,10 @@ class AACPManager {
} }
private fun setControlCommandStatusValue( private fun setControlCommandStatusValue(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: ByteArray
value: ByteArray
) { ) {
val existingStatus = getControlCommandStatus(identifier) val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) { if (existingStatus?.value.contentEquals(value)) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus) controlCommandStatusList.remove(existingStatus)
} }
controlCommandListeners[identifier]?.forEach { listener -> controlCommandListeners[identifier]?.forEach { listener ->
@@ -265,6 +236,7 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>) fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String) fun onShowNearbyUI(sender: String)
fun onEQPacketReceived(eqData: FloatArray)
} }
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> { fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -287,15 +259,13 @@ class AACPManager {
} }
fun registerControlCommandListener( fun registerControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
} }
fun unregisterControlCommandListener( fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners[identifier]?.remove(callback) controlCommandListeners[identifier]?.remove(callback)
} }
@@ -330,8 +300,7 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value) val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
value
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
@@ -340,16 +309,14 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: Byte): Boolean { fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
byteArrayOf(value)
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket( val controlPacket = createControlCommandPacket(
identifier, identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
) )
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false,
@@ -369,8 +336,7 @@ class AACPManager {
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> { fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d( Log.d(
TAG, TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
) )
if (data.size < 4) { if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
@@ -395,14 +361,18 @@ class AACPManager {
} }
val key = ByteArray(keyLength) val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength) System.arraycopy(data, offset, key, 0, keyLength)
try {
keys[ProximityKeyType.fromByte(keyType)] = key keys[ProximityKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
)
}
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
key.joinToString(" ") { "%02X".format(it) } key.joinToString(" ") { "%02X".format(it) }
}" }")
)
} }
return keys return keys
} }
@@ -422,32 +392,33 @@ class AACPManager {
fun receivePacket(packet: ByteArray) { fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) { if (!packet.toHexString().startsWith("04000400")) {
Log.w( Log.w(
TAG, TAG, "Received packet does not start with expected header: ${
"Received packet does not start with expected header: ${
packet.joinToString(" ") { packet.joinToString(" ") {
"%02X".format(it) "%02X".format(it)
} }
}" }")
)
return return
} }
if (packet.size < 6) { if (packet.size < 6) {
Log.w( Log.w(
TAG, TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
) )
return return
} }
val opcode = packet[4] when (val opcode = packet[4]) {
when (opcode) {
Opcodes.BATTERY_INFO -> { Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet) callback?.onBatteryInfoReceived(packet)
} }
Opcodes.CONTROL_COMMAND -> { Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return
}
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value controlCommand.value
@@ -456,10 +427,9 @@ class AACPManager {
TAG, TAG,
"Control command received: ${controlCommand.identifier.toHexString()} - ${ "Control command received: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }")
)
Log.d( val controlCommandListText = try {
TAG, "Control command list is now: ${
controlCommandStatusList.joinToString(", ") { it -> controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString( it.value.joinToString(
@@ -467,12 +437,19 @@ class AACPManager {
) { "%02X".format(it) } ) { "%02X".format(it) }
}" }"
} }
}") } catch (e: Exception) {
e.message
}
Log.d(
TAG, "Control command list is now: $controlCommandListText"
)
val controlCommandIdentifier = val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) { if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
listener.onControlCommandReceived(controlCommand) listener.onControlCommandReceived(controlCommand)
} }
} else { } else {
@@ -500,13 +477,11 @@ class AACPManager {
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
TAG, TAG, "Received HEADTRACKING packet too short: ${
"Received HEADTRACKING packet too short: ${
packet.joinToString(" ") { packet.joinToString(" ") {
"%02X".format(it) "%02X".format(it)
} }
}" }")
)
return return
} }
callback?.onHeadTrackingReceived(packet) callback?.onHeadTrackingReceived(packet)
@@ -538,7 +513,8 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> { Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString() val packetString = packet.decodeToString()
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } val sender =
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
// val nameStartIndex = packetString.indexOf("btName") + 8 // val nameStartIndex = packetString.indexOf("btName") + 8
@@ -558,9 +534,15 @@ class AACPManager {
} else if ("Android" in packetString) { } else if ("Android" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Android" connectedDevices.find { it.mac == sender }?.type = "Android"
} }
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}") Log.d(
TAG,
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
)
if (packetString.contains("SetOwnershipToFalse")) { if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped")) callback?.onOwnershipToFalseRequest(
sender,
packetString.contains("ReverseBannerTapped")
)
} }
if (packetString.contains("ShowNearbyUI")) { if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI(sender) callback?.onShowNearbyUI(sender)
@@ -585,25 +567,33 @@ class AACPManager {
eqOnMedia = (packet[10] == 0x01.toByte()) eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 0x01.toByte()) eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird. // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats // the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq1 =
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
// for now, taking just the first EQ // for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) } eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
Log.d(
TAG,
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
)
callback?.onEQPacketReceived(eqData)
} }
Opcodes.INFORMATION -> { Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet") Log.d(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet) val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
else -> { else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}") Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -633,10 +623,22 @@ class AACPManager {
fun createHandshakePacket(): ByteArray { fun createHandshakePacket(): ByteArray {
return byteArrayOf( return byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x00,
0x01, 0x00, 0x02, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x04,
0x00, 0x00, 0x00, 0x00 0x00,
0x01,
0x00,
0x02,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
) )
} }
@@ -782,17 +784,31 @@ class AACPManager {
} }
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
)
) {
// throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress") Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false return false
} }
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $targetMacAddress") Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress)) return sendDataPacket(
createMediaInformationNewDevicePacket(
selfMacAddress,
targetMacAddress
)
)
} }
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { fun createMediaInformationNewDevicePacket(
selfMacAddress: String,
targetMacAddress: String
): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(116) val buffer = ByteBuffer.allocate(116)
buffer.put( buffer.put(
@@ -881,17 +897,13 @@ class AACPManager {
Log.d(TAG, "Sending Media Information packet to $targetMac") Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket( return sendDataPacket(
createMediaInformationPacket( createMediaInformationPacket(
selfMacAddress, selfMacAddress, targetMac, streamingState
targetMac,
streamingState
) )
) )
} }
fun createMediaInformationPacket( fun createMediaInformationPacket(
selfMacAddress: String, selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
targetMacAddress: String,
streamingState: Boolean = true
): ByteArray { ): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(138) val buffer = ByteBuffer.allocate(138)
@@ -906,7 +918,7 @@ class AACPManager {
) )
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray()) buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
buffer.put(byteArrayOf(0x52)) // 'R' buffer.put(byteArrayOf(0x52)) // 'R'
buffer.put("HostStreamingState".toByteArray()) buffer.put("HostStreamingState".toByteArray())
@@ -1006,9 +1018,15 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
)
) {
// throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress") Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false return false
} }
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
@@ -1042,8 +1060,7 @@ class AACPManager {
} }
data class ControlCommand( data class ControlCommand(
val identifier: Byte, val identifier: Byte, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -1065,25 +1082,25 @@ class AACPManager {
companion object { companion object {
fun fromByteArray(data: ByteArray): ControlCommand { fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) { var offset = 0
throw IllegalArgumentException("Data array too short to parse ControlCommand") while (data.size - offset >= 4 &&
data[offset] == 0x04.toByte() &&
data[offset + 1] == 0x00.toByte() &&
data[offset + 2] == 0x04.toByte() &&
data[offset + 3] == 0x00.toByte()
) {
offset += 4
} }
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) { if (data.size - offset < 7) {
val newData = ByteArray(data.size - 4) throw IllegalArgumentException("Too short for ControlCommand")
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
} }
if (data[0] != Opcodes.CONTROL_COMMAND) { if (data[offset] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode") throw IllegalArgumentException("Invalid opcode")
} }
val identifier = data[2] val identifier = data[offset + 2]
val value = data.copyOfRange(offset + 3, offset + 7)
val value = ByteArray(4) val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
System.arraycopy(data, 3, value, 0, 4) return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
return ControlCommand(identifier, finalValue)
} }
} }
} }
@@ -1095,10 +1112,8 @@ class AACPManager {
triplePressCustomized: Boolean = false, triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false longPressCustomized: Boolean = false
): Boolean { ): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or val value =
(if (doublePressCustomized) 0x02 else 0) or ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand( return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value ControlCommandIdentifiers.STEM_CONFIG.value, value
@@ -1111,21 +1126,26 @@ class AACPManager {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) { if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Invalid control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return false
}
Log.d( Log.d(
TAG, TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
"Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }")
)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value controlCommand.value
) )
} }
val socket = BluetoothConnectionManager.getCurrentSocket() val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
if (socket?.isConnected == true) {
if (socket.isConnected) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
return true return true
@@ -1202,7 +1222,10 @@ class AACPManager {
var offset = 9 var offset = 9
for (i in 0 until deviceCount) { for (i in 0 until deviceCount) {
if (offset + 8 > data.size) { if (offset + 8 > data.size) {
Log.w(TAG, "Data array too short to parse all connected devices, returning what we have") Log.w(
TAG,
"Data array too short to parse all connected devices, returning what we have"
)
break break
} }
val macBytes = data.sliceArray(offset until offset + 6) val macBytes = data.sliceArray(offset until offset + 6)
@@ -1216,6 +1239,7 @@ class AACPManager {
return devices return devices
} }
fun sendSomePacketIDontKnowWhatItIs() { fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff -- enables setting EQ // 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket( sendDataPacket(

View File

@@ -21,17 +21,18 @@
* and receiving notifications. It is not a complete implementation of the ATT protocol. * and receiving notifications. It is not a complete implementation of the ATT protocol.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
@@ -49,7 +50,7 @@ enum class ATTCCCDHandles(val value: Int) {
HEARING_AID(ATTHandles.HEARING_AID.value + 1), HEARING_AID(ATTHandles.HEARING_AID.value + 1),
} }
class ATTManager(private val device: BluetoothDevice) { class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object { companion object {
private const val TAG = "ATTManager" private const val TAG = "ATTManager"
@@ -62,18 +63,26 @@ class ATTManager(private val device: BluetoothDevice) {
private var input: InputStream? = null private var input: InputStream? = null
private var output: OutputStream? = null private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>() private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: kotlinx.coroutines.Job? = null private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests) // queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>() private val responses = LinkedBlockingQueue<ByteArray>()
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun connect() { fun connect() {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
socket = createBluetoothSocket(device, uuid) try {
socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
}
try {
socket!!.connect() socket!!.connect()
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
return
}
input = socket!!.inputStream input = socket!!.inputStream
output = socket!!.outputStream output = socket!!.outputStream
Log.d(TAG, "Connected to ATT") Log.d(TAG, "Connected to ATT")
@@ -163,6 +172,7 @@ class ATTManager(private val device: BluetoothDevice) {
} }
private fun writeRaw(pdu: ByteArray) { private fun writeRaw(pdu: ByteArray) {
if (output == null) return
output?.write(pdu) output?.write(pdu)
output?.flush() output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
@@ -195,9 +205,10 @@ class ATTManager(private val device: BluetoothDevice) {
} }
} }
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP val type = 3 // L2CAP
val constructorSpecs = listOf( val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid), arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid), arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid), arrayOf(type, 1, true, true, device, 31, uuid),
@@ -219,7 +230,12 @@ class ATTManager(private val device: BluetoothDevice) {
try { try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}") Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++ attemptedConstructors++
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}") Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e lastException = e

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket

View File

@@ -1,128 +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.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)
}
}

View File

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

View File

@@ -1,217 +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.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))
)
)
}
}
}
}
}
}
}

View File

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

View File

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

View File

@@ -16,10 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
open class AirPodsBase( open class AirPodsBase(
@@ -52,33 +50,48 @@ enum class Capability {
class AirPods: AirPodsBase( class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"), modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1", name = "AirPods 1",
budCaseRes = R.drawable.airpods_1, // budCaseRes = R.drawable.airpods_1
budsRes = R.drawable.airpods_1_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_1_left, // budsRes = R.drawable.airpods_1_buds
rightBudsRes = R.drawable.airpods_1_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_1_case, // leftBudsRes = R.drawable.airpods_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet() capabilities = emptySet()
) )
class AirPods2: AirPodsBase( class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"), modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2", name = "AirPods 2",
budCaseRes = R.drawable.airpods_2, // budCaseRes = R.drawable.airpods_2
budsRes = R.drawable.airpods_2_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_2_left, // budsRes = R.drawable.airpods_2_buds
rightBudsRes = R.drawable.airpods_2_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_2_case, // leftBudsRes = R.drawable.airpods_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet() capabilities = emptySet()
) )
class AirPods3: AirPodsBase( class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"), modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3", name = "AirPods 3",
budCaseRes = R.drawable.airpods_3, // budCaseRes = R.drawable.airpods_3
budsRes = R.drawable.airpods_3_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_3_left, // budsRes = R.drawable.airpods_3_buds
rightBudsRes = R.drawable.airpods_3_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_3_case, // leftBudsRes = R.drawable.airpods_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.HEAD_GESTURES Capability.HEAD_GESTURES
) )
@@ -87,11 +100,16 @@ class AirPods3: AirPodsBase(
class AirPods4: AirPodsBase( class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"), modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4", name = "AirPods 4",
budCaseRes = R.drawable.airpods_4, // budCaseRes = R.drawable.airpods_4
budsRes = R.drawable.airpods_4_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_4_left, // budsRes = R.drawable.airpods_4_buds
rightBudsRes = R.drawable.airpods_4_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_4_case, // leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.HEAD_GESTURES, Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION, Capability.SLEEP_DETECTION,
@@ -102,18 +120,24 @@ class AirPods4: AirPodsBase(
class AirPods4ANC: AirPodsBase( class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"), modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)", name = "AirPods 4 (ANC)",
budCaseRes = R.drawable.airpods_4, // budCaseRes = R.drawable.airpods_4
budsRes = R.drawable.airpods_4_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_4_left, // budsRes = R.drawable.airpods_4_buds
rightBudsRes = R.drawable.airpods_4_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_4_case, // leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.LISTENING_MODE, Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS, Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES, Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION, Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME Capability.ADAPTIVE_VOLUME,
Capability.STEM_CONFIG
) )
) )
@@ -121,11 +145,16 @@ class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"), modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1", name = "AirPods Pro 1",
displayName = "AirPods Pro", displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_1, // budCaseRes = R.drawable.airpods_pro_1
budsRes = R.drawable.airpods_pro_1_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_pro_1_left, // budsRes = R.drawable.airpods_pro_1_buds
rightBudsRes = R.drawable.airpods_pro_1_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_1_case, // leftBudsRes = R.drawable.airpods_pro_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.LISTENING_MODE Capability.LISTENING_MODE
) )
@@ -135,10 +164,15 @@ class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"), modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)", name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro", displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2, budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds, budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left, leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right, rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case, caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.LISTENING_MODE, Capability.LISTENING_MODE,
@@ -158,10 +192,15 @@ class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"), modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)", name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro", displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_2
budCaseRes = R.drawable.airpods_pro_2, budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds, budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left, leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right, rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
caseRes = R.drawable.airpods_pro_2_case, caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.LISTENING_MODE, Capability.LISTENING_MODE,
@@ -181,11 +220,16 @@ class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"), modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3", name = "AirPods Pro 3",
displayName = "AirPods Pro", displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_3, // budCaseRes = R.drawable.airpods_pro_3
budsRes = R.drawable.airpods_pro_3_buds, budCaseRes = R.drawable.airpods_pro_2,
leftBudsRes = R.drawable.airpods_pro_3_left, // budsRes = R.drawable.airpods_pro_3_buds
rightBudsRes = R.drawable.airpods_pro_3_right, budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_3_case, // leftBudsRes = R.drawable.airpods_pro_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf( capabilities = setOf(
Capability.LISTENING_MODE, Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS, Capability.CONVERSATION_AWARENESS,
@@ -212,8 +256,6 @@ data class AirPodsInstance(
val version1: String?, val version1: String?,
val version2: String?, val version2: String?,
val version3: String?, val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
) )
object AirPodsModels { object AirPodsModels {

View File

@@ -0,0 +1,70 @@
/*
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
}
}
}

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import android.util.Log import android.util.Log
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.data
import android.os.Parcelable import android.os.Parcelable
import android.util.Log import android.util.Log
@@ -40,6 +40,7 @@ object BatteryStatus {
const val CHARGING = 1 const val CHARGING = 1
const val NOT_CHARGING = 2 const val NOT_CHARGING = 2
const val DISCONNECTED = 4 const val DISCONNECTED = 4
const val OPTIMIZED_CHARGING = 5
} }
@Parcelize @Parcelize
@@ -58,6 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
BatteryStatus.CHARGING -> "CHARGING" BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED" BatteryStatus.DISCONNECTED -> "DISCONNECTED"
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
else -> null else -> null
} }
} }
@@ -70,6 +72,7 @@ enum class NoiseControlMode {
class AirPodsNotifications { class AirPodsNotifications {
companion object { companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
@@ -78,6 +81,8 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
} }
class EarDetection { class EarDetection {

View File

@@ -16,9 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
enum class StemAction { enum class StemAction {
PLAY_PAUSE, PLAY_PAUSE,

View File

@@ -16,13 +16,14 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -139,7 +140,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
private var debounceJob: Job? = null private var debounceJob: Job? = null
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) { fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch { debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100) delay(100)
@@ -171,7 +172,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
} }
val data = buffer.array() val data = buffer.array()
attManager.write(ATTHandles.TRANSPARENCY, value = data) writer(ATTHandles.TRANSPARENCY, data)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
} }

View File

@@ -0,0 +1,8 @@
package me.kavishdevar.librepods.data
interface XposedRemotePref {
fun isAvailable(): Boolean
fun getBoolean(key: String, def: Boolean): Boolean
fun putBoolean(key: String, value: Boolean)
}

View File

@@ -0,0 +1,21 @@
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) }
}
}

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}

View File

@@ -18,12 +18,12 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AboutCard(navController: NavController) { fun AboutCard(
navController: NavController,
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box( Box(
@@ -75,7 +75,8 @@ fun AboutCard(navController: NavController) {
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -108,7 +109,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.model.displayName, text = modelName,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -137,7 +138,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.actualModelNumber, text = actualModel,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -152,11 +153,11 @@ fun AboutCard(navController: NavController) {
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
val serialNumbers = listOf( val serialNumbers = listOf(
airpodsInstance.serialNumber?: "", serialNumbers[0],
"􀀛 ${airpodsInstance.leftSerialNumber}", "􀀛 ${serialNumbers[1]}",
"􀀧 ${airpodsInstance.rightSerialNumber}" "􀀧 ${serialNumbers[2]}"
) )
val serialNumber = remember { mutableStateOf(0) } val serialNumber = remember { mutableIntStateOf(0) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -172,7 +173,7 @@ fun AboutCard(navController: NavController) {
), ),
) )
Text( Text(
text = serialNumbers[serialNumber.value], text = serialNumbers[serialNumber.intValue],
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -183,7 +184,7 @@ fun AboutCard(navController: NavController) {
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
} }
) )
} }
@@ -197,7 +198,7 @@ fun AboutCard(navController: NavController) {
to = "version_info", to = "version_info",
navController = navController, navController = navController,
name = stringResource(R.string.version), name = stringResource(R.string.version),
currentState = airpodsInstance.version3, currentState = version,
independent = false, independent = false,
height = rowHeight.value + 32.dp height = rowHeight.value + 32.dp
) )

View File

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

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -42,25 +44,32 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings(navController: NavController) { fun AudioSettings(
navController: NavController,
adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
conversationalAwarenessChecked: Boolean,
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
vendorIdHook: Boolean,
isPremium: Boolean
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return return
} }
Box( Box(
@@ -73,7 +82,8 @@ fun AudioSettings(navController: NavController) {
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -88,12 +98,14 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) { if (adaptiveVolumeCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, independent = false,
independent = false checked = adaptiveVolumeChecked,
onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
@@ -104,12 +116,14 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) { if (conversationalAwarenessCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, independent = false,
independent = false checked = conversationalAwarenessChecked,
onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -119,12 +133,14 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ if (loudSoundReductionCapability && vendorIdHook){
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION, independent = false,
independent = false checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -134,7 +150,7 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) { if (adaptiveAudioCapability) {
NavigationButton( NavigationButton(
to = "adaptive_strength", to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
@@ -148,5 +164,19 @@ fun AudioSettings(navController: NavController) {
@Preview @Preview
@Composable @Composable
fun AudioSettingsPreview() { fun AudioSettingsPreview() {
AudioSettings(rememberNavController()) AudioSettings(
navController = rememberNavController(),
adaptiveVolumeCapability = true,
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
vendorIdHook = true,
isPremium = true
)
} }

View File

@@ -0,0 +1,214 @@
/*
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
status: Int,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
) {
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
val optimizedLimit = 0.8f
val progress = batteryPercentage / 100f
Canvas(modifier = Modifier.size(34.dp)) {
val startAngle = -90f
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
val inset = strokeWidthPx / 2
Rect(
left = inset,
top = inset,
right = size.width - inset,
bottom = size.height - inset
)
val radius = size.minDimension / 2
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
drawArc(
color = trackColor,
startAngle = startAngle,
sweepAngle = 360f * optimizedLimit,
useCenter = false,
style = stroke
)
val sweep = 360f * min(progress, optimizedLimit)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
style = stroke
)
// ---- PILL MARKER AT 80% ----
val angleDeg = startAngle + 360f * optimizedLimit
val angleRad = Math.toRadians(angleDeg.toDouble())
val arcRadius = radius - strokeWidthPx
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
val dirX = center.x - outerX
val dirY = center.y - outerY
val length = sqrt(dirX * dirX + dirY * dirY)
val normX = dirX / length
val normY = dirY / length
val startX = outerX - normX * strokeWidthPx / 2
val startY = outerY - normY * strokeWidthPx / 2
val endX = center.x - normX * gapFromCenterPx
val endY = center.y - normY * gapFromCenterPx
drawLine(
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
start = Offset(startX, startY),
end = Offset(endX, endY),
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
} else {
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = stroke
)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = 360f * progress,
useCenter = false,
style = stroke
)
}
}
Text(
text = "\uDBC0\uDEE6", style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
), modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(
batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING,
prefix = "\uDBC6\uDCE5",
previousCharging = false
)
}
}

View File

@@ -0,0 +1,177 @@
/*
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 caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.widthIn(max = 500.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
singleDisplayed.value = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(bg)
.padding(16.dp)
) {
BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -36,39 +36,43 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings(hazeState: HazeState) { fun CallControlSettings(
hazeState: HazeState,
flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -82,7 +86,8 @@ fun CallControlSettings(hazeState: HazeState) {
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -93,24 +98,13 @@ fun CallControlSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find { val scope = rememberCoroutineScope()
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG val haptics = LocalHapticFeedback.current
}?.value ?: byteArrayOf(0x00, 0x03)
val pressOnceText = stringResource(R.string.press_once) val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice) val pressTwiceText = stringResource(R.string.press_twice)
var flipped by remember {
mutableStateOf(
callControlEnabledValue.contentEquals(
byteArrayOf(
0x00,
0x02
)
)
)
}
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
@@ -120,6 +114,7 @@ fun CallControlSettings(hazeState: HazeState) {
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) } var parentDragActiveSingle by remember { mutableStateOf(false) }
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
var showDoublePressDropdown by remember { mutableStateOf(false) } var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) } var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
@@ -127,36 +122,8 @@ fun CallControlSettings(hazeState: HazeState) {
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) } var parentDragActiveDouble by remember { mutableStateOf(false) }
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
Log.d(
"CallControlSettings",
"Control command received, flipped: $newFlipped"
)
}
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
}
}
LaunchedEffect(flipped) { LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped") Log.d("CallControlSettings", "Call control flipped: $flipped")
} }
@@ -231,7 +198,11 @@ fun CallControlSettings(hazeState: HazeState) {
val touch = touchOffsetSingle ?: current val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxSingle) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexSingle = idx parentHoveredIndexSingle = idx
previousIdxSingle = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveSingle = false parentDragActiveSingle = false
@@ -244,13 +215,13 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis() lastDismissTimeSingle = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( onCallControlValueChanged(option != pressOnceText)
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexSingle = null parentHoveredIndexSingle = null
}, },
onDragCancel = { onDragCancel = {
@@ -313,11 +284,8 @@ fun CallControlSettings(hazeState: HazeState) {
doublePressAction = doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option != pressOnceText
0x00, onCallControlValueChanged(flipped)
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -366,7 +334,11 @@ fun CallControlSettings(hazeState: HazeState) {
val touch = touchOffsetDouble ?: current val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxDouble) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexDouble = idx parentHoveredIndexDouble = idx
previousIdxDouble = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveDouble = false parentDragActiveDouble = false
@@ -379,13 +351,13 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis() lastDismissTimeDouble = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged(flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexDouble = null parentHoveredIndexDouble = null
}, },
onDragCancel = { onDragCancel = {
@@ -448,11 +420,8 @@ fun CallControlSettings(hazeState: HazeState) {
singlePressAction = singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged(flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -461,10 +430,3 @@ fun CallControlSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings(HazeState())
}

View File

@@ -0,0 +1,179 @@
/*
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.Text
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 com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@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,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
AnimatedVisibility(
visible = showDialog.value,
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut()
) {
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
) {
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 = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = dismissText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = textColor
)
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
surfaceColor = accentColor
) {
Text(
text = confirmText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = Color.White
)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}
}

View File

@@ -18,9 +18,8 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,16 +30,18 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun ConnectionSettings() { fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -52,10 +53,9 @@ fun ConnectionSettings() {
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.ear_detection), label = stringResource(R.string.ear_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG, independent = false,
sharedPreferenceKey = "automatic_ear_detection", checked = automaticEarDetectionEnabled,
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), onCheckedChange = onAutomaticEarDetectionChanged
independent = false
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -67,16 +67,9 @@ fun ConnectionSettings() {
StyledToggle( StyledToggle(
label = stringResource(R.string.automatically_connect), label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description), description = stringResource(R.string.automatically_connect_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, independent = false,
sharedPreferenceKey = "automatic_connection_ctrl_cmd", checked = automaticConnectionEnabled,
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), onCheckedChange = onAutomaticConnectionChanged
independent = false
) )
} }
} }
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}

View File

@@ -18,7 +18,7 @@
@file:Suppress("unused") @file:Suppress("unused")
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E) private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)

View File

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

View File

@@ -0,0 +1,118 @@
/*
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(
navController: NavController,
hasPPECapability: Boolean,
hasHearingAidCapability: Boolean,
vendorIdHook: Boolean
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else if (shouldShowHearingAid) {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
} else if (hasPPECapability) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
title = stringResource(R.string.hearing_health),
navController = navController
)
}
}

View File

@@ -18,9 +18,8 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -35,38 +34,41 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun MicrophoneSettings(hazeState: HazeState) { fun MicrophoneSettings(
hazeState: HazeState,
micModeValue: Byte,
onMicModeValueChanged: (Byte) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -77,11 +79,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val micModeValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
}?.value?.get(0) ?: 0x00.toByte()
var selectedMode by remember { var selectedMode by remember {
mutableStateOf( mutableStateOf(
when (micModeValue) { when (micModeValue) {
@@ -98,42 +95,13 @@ fun MicrophoneSettings(hazeState: HazeState) {
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L val reopenThresholdMs = 250L
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
) {
selectedMode = when (controlCommand.value[0]) {
0x00.toByte() -> "Automatic"
0x01.toByte() -> "Always Right"
0x02.toByte() -> "Always Left"
else -> "Automatic"
}
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
}
val density = LocalDensity.current val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() } val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val microphoneAutomaticText = stringResource(R.string.microphone_automatic) val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
@@ -173,7 +141,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx parentHoveredIndex = idx
previousIdx = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActive = false parentDragActive = false
@@ -194,12 +166,16 @@ fun MicrophoneSettings(hazeState: HazeState) {
options[2] -> 0x02 options[2] -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( // service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, // AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte()) // byteArrayOf(byteValue.toByte())
) // )
onMicModeValueChanged(byteValue.toByte())
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null parentHoveredIndex = null
}, },
onDragCancel = { onDragCancel = {
@@ -277,10 +253,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
microphoneAlwaysLeftText -> 0x02 microphoneAlwaysLeftText -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( onMicModeValueChanged(byteValue.toByte())
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -288,10 +261,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
MicrophoneSettings(HazeState())
}

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@Composable @Composable
@@ -62,10 +66,14 @@ fun NavigationButton(
description: String? = null, description: String? = null,
currentState: String? = null, currentState: String? = null,
height: Dp = 58.dp, height: Dp = 58.dp,
enabled: Boolean = true
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column { Column {
if (title != null) { if (title != null) {
Box( Box(
@@ -79,24 +87,35 @@ fun NavigationButton(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
} }
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) .background(
animatedBackgroundColor,
RoundedCornerShape(if (independent) 28.dp else 0.dp)
)
.height(height) .height(height)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease() tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
}, },
onTap = { onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
if (onClick != null) onClick() else navController.navigate(to) if (onClick != null) onClick() else navController.navigate(to)
} }
}
) )
} }
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource

View File

@@ -18,14 +18,9 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
@@ -60,64 +55,49 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
service: AirPodsService, showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit
) { ) {
val context = LocalContext.current
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] == 1.toByte()
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) } val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) } val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) } val d3a = remember { mutableFloatStateOf(0f) }
// this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value val previousMode = noiseControlMode.value
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) { val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY NoiseControlMode.TRANSPARENCY
} else { } else {
mode mode
@@ -125,9 +105,8 @@ fun NoiseControlSettings(
noiseControlMode.value = targetMode noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) { if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
when (noiseControlMode.value) { when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> { NoiseControlMode.NOISE_CANCELLATION -> {
@@ -153,32 +132,12 @@ fun NoiseControlSettings(
} }
} }
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
onModeSelected(noiseControlMode.value, true)
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
val noiseControlIntentFilter = IntentFilter().apply { val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
addAction(AirPodsNotifications.ANC_DATA) noiseControlMode.value = NoiseControlMode.entries[index]
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
} onModeSelected(noiseControlMode.value, received = true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -190,17 +149,18 @@ fun NoiseControlSettings(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val buttonCount = if (offListeningMode.value) 4 else 3 val buttonCount = if (showOffListeningMode) 4 else 3
val buttonWidth = maxWidth / buttonCount val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) } val isDragging = remember { mutableStateOf(false) }
@@ -208,10 +168,10 @@ fun NoiseControlSettings(
mutableFloatStateOf( mutableFloatStateOf(
with(density) { with(density) {
when(noiseControlMode.value) { when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx() NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx() NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
} }
} }
) )
@@ -224,10 +184,10 @@ fun NoiseControlSettings(
) )
val targetOffset = buttonWidth * when(noiseControlMode.value) { val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1 NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0 NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1 NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2 NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
} }
val animatedOffset by animateFloatAsState( val animatedOffset by animateFloatAsState(
@@ -250,7 +210,7 @@ fun NoiseControlSettings(
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -323,13 +283,12 @@ fun NoiseControlSettings(
val position = dragOffset / with(density) { buttonWidth.toPx() } val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt() val newIndex = position.roundToInt()
val newMode = when(newIndex) { val newMode = when(newIndex) {
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION 3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid else -> noiseControlMode.value // Keep current if index is invalid
} }
// Call onModeSelected which now handles service call but not callback
onModeSelected(newMode) onModeSelected(newMode)
} }
) )
@@ -347,7 +306,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.zIndex(1f) .zIndex(1f)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -406,7 +365,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp) .padding(top = 4.dp)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
Text( Text(
text = stringResource(R.string.off), text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor), style = TextStyle(fontSize = 12.sp, color = textColor),
@@ -436,9 +395,3 @@ fun NoiseControlSettings(
} }
} }
} }
@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}

View File

@@ -16,17 +16,13 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -35,38 +31,34 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.data.StemAction
@Composable @Composable
fun PressAndHoldSettings(navController: NavController) { fun PressAndHoldSettings(
navController: NavController,
leftAction: StemAction,
rightAction: StemAction
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888) val dividerColor = Color(0x40888888)
val context = LocalContext.current val leftActionText = when (leftAction) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) { val rightActionText = when (rightAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
) )
} }
} }
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}

View File

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

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastCoerceAtMost
@@ -55,7 +57,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -75,9 +77,12 @@ fun StyledButton(
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val animationScope = rememberCoroutineScope() val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) } var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
@@ -122,8 +127,8 @@ half4 main(float2 coord) {
} else { } else {
drawRect(Color.White.copy(0.1f)) drawRect(Color.White.copy(0.1f))
} }
if (surfaceColor.isSpecified) { if (surfaceColor.isSpecified && enabled) {
val color = if (!isInteractive && isPressed) { val color = if (isPressed) {
Color( Color(
red = surfaceColor.red * 0.5f, red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f, green = surfaceColor.green * 0.5f,
@@ -134,6 +139,11 @@ half4 main(float2 coord) {
surfaceColor surfaceColor
} }
drawRect(color) drawRect(color)
} else {
if (isPressed) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
} }
}, },
onDrawFront = null, onDrawFront = null,
@@ -146,7 +156,12 @@ half4 main(float2 coord) {
effects = { effects = {
vibrancy() vibrancy()
blur(2f.dp.toPx()) blur(2f.dp.toPx())
refraction(12f.dp.toPx(), 24f.dp.toPx()) lens(
refractionHeight = 12f.dp.toPx(),
refractionAmount = 24f.dp.toPx(),
depthEffect = true,
chromaticAberration = true
)
}, },
layerBlock = { layerBlock = {
val width = size.width val width = size.width
@@ -158,8 +173,10 @@ half4 main(float2 coord) {
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
val offset = offsetAnimation.value val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) translationX =
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
@@ -204,7 +221,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -231,31 +251,53 @@ half4 main(float2 coord) {
interactionSource = null, interactionSource = null,
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = onClick onClick = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
}
) )
.then( .then(
if (isInteractive) { if (isInteractive) {
Modifier.pointerInput(animationScope) { Modifier.pointerInput(scope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
} }
} }
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}, },
onDragEnd = { onDragStop() }, onDragEnd = {
onDragStop()
},
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -269,8 +311,11 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
}
) )
} }
} }

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -110,6 +114,9 @@ fun StyledDropdown(
var hoveredIndex by remember { mutableStateOf<Int?>(null) } var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp val itemHeight = 48.dp
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var popupSize by remember { mutableStateOf(IntSize(0, 0)) } var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) } var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
@@ -132,7 +139,12 @@ fun StyledDropdown(
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
val y = change.position.y val y = change.position.y
hoveredIndex = (y / itemHeight.toPx()).toInt() val newHoveredIndex = (y / itemHeight.toPx()).toInt()
if (newHoveredIndex != hoveredIndex) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
}
hoveredIndex = newHoveredIndex
lastDragPosition = change.position lastDragPosition = change.position
}, },
onDragEnd = { onDragEnd = {
@@ -144,6 +156,8 @@ fun StyledDropdown(
if (withinBounds) { if (withinBounds) {
hoveredIndex?.let { idx -> hoveredIndex?.let { idx ->
if (idx in options.indices) { if (idx in options.indices) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd) }
onOptionSelected(options[idx]) onOptionSelected(options[idx])
} }
} }
@@ -174,6 +188,7 @@ fun StyledDropdown(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onOptionSelected(text) onOptionSelected(text)
onDismissRequest() onDismissRequest()
} }

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -50,7 +50,10 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -63,8 +66,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -78,14 +80,17 @@ import kotlin.math.tanh
@Composable @Composable
fun StyledIconButton( fun StyledIconButton(
onClick: () -> Unit,
icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true
) { ) {
val animationScope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
@@ -94,6 +99,7 @@ fun StyledIconButton(
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val density = LocalDensity.current
val interactiveHighlightShader = remember { val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -117,7 +123,12 @@ half4 main(float2 coord) {
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
TextButton( TextButton(
onClick = onClick, onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
}
},
shape = RoundedCornerShape(56.dp), shape = RoundedCornerShape(56.dp),
modifier = modifier modifier = modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
@@ -132,6 +143,7 @@ half4 main(float2 coord) {
) )
}, },
layerBlock = { layerBlock = {
if (!enabled) return@drawBackdrop
val width = size.width val width = size.width
val height = size.height val height = size.height
@@ -156,6 +168,12 @@ half4 main(float2 coord) {
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
if (!enabled) {
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
)
return@drawBackdrop
}
val progress = progressAnimation.value.coerceIn(0f, 1f) val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp) val shape = RoundedCornerShape(56.dp)
@@ -182,11 +200,21 @@ half4 main(float2 coord) {
} }
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
if (surfaceColor.isSpecified) {
drawRect(surfaceColor)
}
drawRect( drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f)) (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn(
0.15f,
0.35f
)
)
) )
}, },
onDrawFront = { onDrawFront = {
if (!enabled) return@drawBackdrop
val progress = progressAnimation.value.fastCoerceIn(0f, 1f) val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) { if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
@@ -197,7 +225,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -218,41 +249,56 @@ half4 main(float2 coord) {
} }
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
// blur(24f, TileMode.Decal) refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
}, },
) )
.pointerInput(animationScope) { .pointerInput(scope) {
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
} }
} }
}
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
if (enabled) {
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}
}, },
onDragEnd = { onDragStop() }, onDragEnd = { onDragStop() },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (enabled) {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
} }
.size(48.dp), }
.size(with(density) { 48.sp.toDp() }),
) { ) {
Text( Text(
text = icon, text = icon,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 20.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black, color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )

View File

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

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -49,7 +49,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
@@ -57,17 +56,15 @@ import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
@@ -80,14 +77,14 @@ fun StyledScaffold(
.clip(RoundedCornerShape(52.dp)) .clip(RoundedCornerShape(52.dp))
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding() val bottomPadding = paddingValues.calculateBottomPadding() + 16.dp
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding) .padding(start = startPadding, end = endPadding)
) { ) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
Box( Box(
@@ -127,13 +124,12 @@ fun StyledScaffold(
} }
} }
content(topPadding + 64.dp, hazeState) content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -145,12 +141,11 @@ fun StyledScaffold(
title = title, title = title,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { _, _ -> ) { _, _, _->
content() content()
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -162,7 +157,7 @@ fun StyledScaffold(
title = title, title = title,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerValue, _ -> ) { spacerValue, _, _ ->
content(spacerValue) content(spacerValue)
} }
} }

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,12 +44,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -59,19 +61,10 @@ data class SelectItem(
val iconRes: Int? = null, val iconRes: Int? = null,
val selected: Boolean, val selected: Boolean,
val onClick: () -> Unit, val onClick: () -> Unit,
val visible: Boolean = true,
val enabled: Boolean = true val enabled: Boolean = true
) )
data class SelectItem2(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: () -> Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
@Composable @Composable
fun StyledSelectList( fun StyledSelectList(
items: List<SelectItem>, items: List<SelectItem>,
@@ -81,38 +74,49 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)), .background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val visibleItems = items.filter { it.enabled } val visibleItems = items.filter { it.visible }
visibleItems.forEachIndexed { index, item -> visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0 val isFirst = index == 0
val isLast = index == visibleItems.size - 1 val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null val hasIcon = item.iconRes != null
val shape = when { val shape = when {
isFirst && isLast -> RoundedCornerShape(28.dp)
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp) else -> RoundedCornerShape(0.dp)
} }
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) } var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row( Row(
modifier = Modifier modifier = Modifier
.height(if (hasIcon) 72.dp else 55.dp) .heightIn(min = if (hasIcon) 72.dp else 55.dp)
.background(animatedBackgroundColor, shape) .background(animatedBackgroundColor, shape)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (item.enabled) {
itemBackgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease() tryAwaitRelease()
itemBackgroundColor = backgroundColor itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
item.onClick() item.onClick()
} }
}
) )
} }
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
@@ -121,7 +125,7 @@ fun StyledSelectList(
) { ) {
if (hasIcon) { if (hasIcon) {
Icon( Icon(
painter = painterResource(item.iconRes!!), painter = painterResource(item.iconRes),
contentDescription = "Icon", contentDescription = "Icon",
tint = Color(0xFF007AFF), tint = Color(0xFF007AFF),
modifier = Modifier modifier = Modifier

View File

@@ -16,8 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log import android.util.Log
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.input.pointer.util.addPointerInputChange
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -81,7 +83,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
@@ -203,10 +205,11 @@ class MomentumAnimation(
} }
} }
@SuppressLint("UnrememberedMutableState")
@Composable @Composable
fun StyledSlider( fun StyledSlider(
label: String? = null, label: String? = null,
mutableFloatState: MutableFloatState, value: Float,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(), backdrop: Backdrop = rememberLayerBackdrop(),
@@ -217,24 +220,27 @@ fun StyledSlider(
startLabel: String? = null, startLabel: String? = null,
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false,
description: String? = null description: String? = null,
enabled: Boolean = true
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
val accentColor =
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
val trackColor = val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f) if (isLightTheme) Color(0xFF787878).copy(0.2f)
else Color(0xFF787880).copy(0.36f) else Color(0xFF787880).copy(0.36f)
val accentColor =
if (enabled) {
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
} else {
trackColor
}
val labelTextColor = if (isLightTheme) Color.Black else Color.White val labelTextColor = if (isLightTheme) Color.Black else Color.White
val fraction by remember { val fraction by derivedStateOf {
derivedStateOf { ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f) .fastCoerceIn(0f, 1f)
} }
}
val sliderBackdrop = rememberLayerBackdrop() val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) } val trackWidthState = remember { mutableFloatStateOf(0f) }
@@ -242,6 +248,8 @@ fun StyledSlider(
val startIconWidthState = remember { mutableFloatStateOf(0f) } val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) } val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
@@ -427,18 +435,35 @@ fun StyledSlider(
) )
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
} }
.then(
if (enabled) {
Modifier
.draggable( .draggable(
rememberDraggableState { delta -> rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) { if (trackWidth > 0f) {
val targetFraction = fraction + delta / trackWidth val targetFraction = fraction + delta / trackWidth
val targetValue = val targetValue =
lerp(valueRange.start, valueRange.endInclusive, targetFraction) lerp(
.fastCoerceIn(valueRange.start, valueRange.endInclusive) 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( val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue, targetValue,
snapPoints, snapPoints,
snapThreshold snapThreshold,
) else targetValue ) else targetValue
onValueChange(snappedValue) onValueChange(snappedValue)
} }
@@ -446,11 +471,10 @@ fun StyledSlider(
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
// Remove this block as momentumAnimation handles pressing lastDragValue = value
}, },
onDragStopped = { onDragStopped = {
// Remove this block as momentumAnimation handles pressing onValueChange((value * 100).roundToInt() / 100f)
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
} }
) )
.then(momentumAnimation.modifier) .then(momentumAnimation.modifier)
@@ -488,12 +512,18 @@ fun StyledSlider(
effects = { effects = {
val progress = momentumAnimation.progress val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress)) blur(8f.dp.toPx() * (1f - progress))
refractionWithDispersion( lens(
height = 6f.dp.toPx() * progress, refractionHeight = 6f.dp.toPx() * progress,
amount = size.height / 2f * progress refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
) )
} }
) )
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp) .size(40f.dp, 24f.dp)
) )
} }
@@ -566,12 +596,13 @@ fun StyledSliderPreview() {
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
Box ( Column (
Modifier.align(Alignment.Center) Modifier.align(Alignment.Center),
verticalArrangement = Arrangement.spacedBy(16.dp)
) )
{ {
StyledSlider( StyledSlider(
mutableFloatState = a, value = a.floatValue,
onValueChange = { onValueChange = {
a.floatValue = it a.floatValue = it
}, },
@@ -582,6 +613,19 @@ fun StyledSliderPreview() {
startIcon = "A", startIcon = "A",
endIcon = "B", endIcon = "B",
) )
StyledSlider(
value = a.floatValue,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
enabled = false
)
} }
} }
} }

View File

@@ -16,10 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.Animatable import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastCoerceIn
@@ -68,11 +70,12 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable @Composable
fun StyledSwitch( fun StyledSwitch(
@@ -81,9 +84,12 @@ fun StyledSwitch(
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
0x805B5B5E
) else Color(0xFFD1D1D6)
val trackWidth = 64.dp val trackWidth = 64.dp
val trackHeight = 28.dp val trackHeight = 28.dp
@@ -98,24 +104,25 @@ fun StyledSwitch(
val animatedFraction = remember { Animatable(fraction) } val animatedFraction = remember { Animatable(fraction) }
val trackWidthPx = remember { mutableFloatStateOf(0f) } val trackWidthPx = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val animationScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } val targetColor = if (checked) onColor else offColor
val animatedTrackColor by animateColorAsState(targetColor)
val totalDrag = remember { mutableFloatStateOf(0f) } val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) } val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
if (!isFirstComposition.value) { if (!isFirstComposition.value) {
coroutineScope { if (checked) {
launch { haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
val targetColor = if (checked) onColor else offColor } else {
animatedTrackColor.animateTo(targetColor, colorAnimationSpec) haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
} }
coroutineScope {
launch { launch {
val targetFrac = if (checked) 1f else 0f val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec) animatedFraction.animateTo(targetFrac, progressAnimationSpec)
@@ -140,7 +147,7 @@ fun StyledSwitch(
modifier = Modifier modifier = Modifier
.layerBackdrop(switchBackdrop) .layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2)) .clip(RoundedCornerShape(trackHeight / 2))
.background(animatedTrackColor.value) .background(animatedTrackColor)
.width(trackWidth) .width(trackWidth)
.height(trackHeight) .height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -154,27 +161,31 @@ fun StyledSwitch(
.then(if (enabled) Modifier.draggable( .then(if (enabled) Modifier.draggable(
rememberDraggableState { delta -> rememberDraggableState { delta ->
if (trackWidthPx.floatValue > 0f) { if (trackWidthPx.floatValue > 0f) {
val oldFraction = animatedFraction.value
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
animationScope.launch { scope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += kotlin.math.abs(delta) totalDrag.floatValue += abs(delta)
val newChecked = newFraction >= 0.5f val newChecked = newFraction >= 0.5f
if (newChecked != checked) { if (newChecked != checked) {
onCheckedChange(newChecked) onCheckedChange(newChecked)
} }
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
} }
}, },
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
totalDrag.floatValue = 0f totalDrag.floatValue = 0f
animationScope.launch { scope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
animationScope.launch { scope.launch {
if (totalDrag.floatValue < tapThreshold) { if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked val newChecked = !checked
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -262,7 +273,12 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress)) drawRect(Color.White.copy(1f - progress))
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
} }
) )
.width(thumbWidth) .width(thumbWidth)

View File

@@ -0,0 +1,273 @@
/*
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.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checked: Boolean = false,
independent: Boolean = true,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
val currentChecked by rememberUpdatedState(checked)
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
var backgroundColor by remember {
mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
)
}
val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor,
animationSpec = tween(durationMillis = 500)
)
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 4.dp
)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
},
onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(it)
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(
if (isDarkTheme) Color(0xFF000000)
else Color(0xFFF2F2F7)
)
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
onCheckedChange(it)
}
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val checked = remember { mutableStateOf(false) }
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT FrameLayout.LayoutParams.WRAP_CONTENT
) )
containerView.addView(islandView, containerParams) containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams( params = WindowManager.LayoutParams(
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
videoView.start() videoView.start()
} }
try {
windowManager.addView(containerView, params) windowManager.addView(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
islandView.post { islandView.post {
initialHeight = islandView.height initialHeight = islandView.height
@@ -707,8 +712,16 @@ class IslandWindow(private val context: Context) {
} }
isClosing = false isClosing = false
// Make sure all animations are canceled // Make sure all animations are canceled
try {
springAnimation.cancel() springAnimation.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling spring animation $e")
}
try {
flingAnimator.cancel() flingAnimator.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling fling animation $e")
}
} }
fun forceClose() { fun forceClose() {

View File

@@ -17,7 +17,7 @@
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.VideoView import android.widget.VideoView
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
@SuppressLint("InflateParams", "ClickableViewAccessibility") @SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow( class PopupWindow(
@@ -64,10 +64,16 @@ class PopupWindow(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
height = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT
width = WindowManager.LayoutParams.MATCH_PARENT val displayMetrics = context.resources.displayMetrics
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
width = if (screenWidthDp >= 600) {
(400 * displayMetrics.density).toInt()
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT format = PixelFormat.TRANSLUCENT
gravity = Gravity.BOTTOM gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
dimAmount = 0.3f dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_FULLSCREEN or
@@ -84,7 +90,6 @@ class PopupWindow(
mParams.x = 0 mParams.x = 0
mParams.y = 0 mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener { mView.setOnClickListener {
close() close()
} }
@@ -139,7 +144,11 @@ class PopupWindow(
vid.start() vid.start()
} }
try {
mWindowManager.addView(mView, mParams) mWindowManager.addView(mView, mParams)
} catch (e: Exception) {
e.printStackTrace()
}
val displayMetrics = mView.context.resources.displayMetrics val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels val screenHeight = displayMetrics.heightPixels

View File

@@ -16,8 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -39,23 +40,26 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -75,74 +79,45 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.components.StyledDropdown
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.utils.Capability import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null private var toneVolumeDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings" //private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AccessibilitySettingsScreen(navController: NavController) { fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable =
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val hearingAidEnabled =
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) 1
)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() } ?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
val hearingAidEnabled = remember { mutableStateOf( )?.toInt() == 1
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
) }
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.accessibility) title = stringResource(R.string.accessibility)
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -152,86 +127,66 @@ fun AccessibilitySettingsScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } if (!state.isPremium) {
val phoneEQEnabled = remember { mutableStateOf(false) } StyledButton(
val mediaEQEnabled = remember { mutableStateOf(false) } onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue = val selectedPressSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
?.get(0) 0
)
var selectedPressSpeed by remember { var selectedPressSpeed by remember {
mutableStateOf( mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
) )
} }
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
}
val pressAndHoldDurationOptions = mapOf( val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue = val selectedPressAndHoldDurationValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
?.get(0) 0
)
var selectedPressAndHoldDuration by remember { var selectedPressAndHoldDuration by remember {
mutableStateOf( mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0] ?: pressAndHoldDurationOptions[0]
) )
} }
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressAndHoldDuration =
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
}
val volumeSwipeSpeedOptions = mapOf( val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option), 1.toByte() to stringResource(R.string.default_option),
@@ -239,85 +194,52 @@ fun AccessibilitySettingsScreen(navController: NavController) {
3.toByte() to stringResource(R.string.longest) 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = val selectedVolumeSwipeSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() } state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
?.get(0) 0
)
var selectedVolumeSwipeSpeed by remember { var selectedVolumeSwipeSpeed by remember {
mutableStateOf( mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1] ?: volumeSwipeSpeedOptions[1]
) )
} }
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) { val phoneEQEnabled = remember { mutableStateOf(false) }
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) val mediaEQEnabled = remember { mutableStateOf(false) }
selectedVolumeSwipeSpeed =
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
selectedVolumeSwipeSpeedListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
selectedVolumeSwipeSpeedListener
)
}
}
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel() phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150) delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch
}
try { try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d( Log.d(
TAG, "AccessibilitySettingsScreen",
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
) )
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}") Log.w(
} "AccessibilitySettingsScreen",
} "Error sending phone/media EQ: ${e.message}"
}
val toneVolumeValue = remember { mutableFloatStateOf(
aacpManager?.controlCommandStatusList?.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
) }
LaunchedEffect(toneVolumeValue.floatValue) {
toneVolumeDebounceJob?.cancel()
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
return@launch
}
try {
manager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
) )
} catch (e: Exception) {
Log.w(TAG, "Error sending tone volume: ${e.message}")
} }
} }
} }
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description), description = stringResource(R.string.press_speed_description),
@@ -325,8 +247,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option), selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressSpeed = newValue selectedPressSpeed = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte() ?: 0.toByte()
) )
@@ -335,16 +257,30 @@ fun AccessibilitySettingsScreen(navController: NavController) {
hazeState = hazeState, hazeState = hazeState,
independent = true independent = true
) )
}
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration), label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description), description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(), options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option), selectedOption = selectedPressAndHoldDuration
?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte() ?: 0.toByte()
) )
@@ -353,61 +289,108 @@ fun AccessibilitySettingsScreen(navController: NavController) {
hazeState = hazeState, hazeState = hazeState,
independent = true independent = true
) )
}
StyledToggle( StyledToggle(
title = stringResource(R.string.noise_control), title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium
) )
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = state.isPremium
) )
} }
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled && state.vendorIdHook) {
NavigationButton( NavigationButton(
to = "transparency_customization", to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), name = stringResource(R.string.customize_transparency_mode),
navController = navController navController = navController,
enabled = state.isPremium
) )
} }
val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
StyledSlider( StyledSlider(
label = stringResource(R.string.tone_volume), label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description), description = stringResource(R.string.tone_volume_description),
mutableFloatState = toneVolumeValue, value = toneVolumeValue,
onValueChange = { onValueChange = {
toneVolumeValue.floatValue = it viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1", startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9", endIcon = "\uDBC0\uDEA9",
independent = true independent = true,
enabled = state.isPremium
) )
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium
) )
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description), description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(), options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option), selectedOption = selectedVolumeSwipeSpeed
?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte() ?: 1.toByte()
) )
@@ -417,22 +400,24 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true independent = true
) )
} }
}
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { // if (!hearingAidEnabled && XposedState.isAvailable) {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to), style = TextStyle(
// style = TextStyle(
// fontSize = 14.sp, // fontSize = 14.sp,
// fontWeight = FontWeight.Bold, // fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f), // color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro)) // fontFamily = FontFamily(Font(R.font.sf_pro))
// ), // ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
// ) // )
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(vertical = 0.dp) // .padding(vertical = 0.dp)
// ) { // ) {
// val darkModeLocal = isSystemInDarkTheme() // val darkModeLocal = isSystemInDarkTheme()
@@ -459,17 +444,19 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// phoneEQEnabled.value = !phoneEQEnabled.value // 0xFFFFFFFF
// }
// ) // )
// phoneEQEnabled.value = !phoneEQEnabled.value
// })
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.phone), // stringResource(R.string.phone),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -495,8 +482,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// } // }
// //
// HorizontalDivider( // HorizontalDivider(
// thickness = 1.dp, // thickness = 1.dp, color = Color(0x40888888)
// color = Color(0x40888888)
// ) // )
// //
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) // val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
@@ -521,17 +507,19 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// mediaEQEnabled.value = !mediaEQEnabled.value // 0xFFFFFFFF
// }
// ) // )
// mediaEQEnabled.value = !mediaEQEnabled.value
// })
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.media), // stringResource(R.string.media),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -556,15 +544,21 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// ) // )
// } // }
// } // }
//
// EQ Settings. Don't seem to have an effect? //// EQ Settings. Don't seem to have an effect?
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// .padding(12.dp), // if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// horizontalAlignment = Alignment.CenterHorizontally // 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) { // for (i in 0 until 8) {
// val eqPhoneValue = // val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
@@ -581,7 +575,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// color = textColor, // color = textColor,
// modifier = Modifier.padding(bottom = 4.dp) // modifier = Modifier.padding(bottom = 4.dp)
// ) // )
//
// Slider( // Slider(
// value = eqPhoneValue.floatValue, // value = eqPhoneValue.floatValue,
// onValueChange = { newVal -> // onValueChange = { newVal ->
@@ -613,8 +607,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// .fillMaxWidth() // .fillMaxWidth()
// .height(12.dp), // .height(12.dp),
// contentAlignment = Alignment.CenterStart // contentAlignment = Alignment.CenterStart
// ) // ) {
// {
// Box( // Box(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
@@ -625,12 +618,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f) // .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp) // .height(4.dp)
// .background(activeTrackColor, RoundedCornerShape(4.dp)) // .background(
// activeTrackColor, RoundedCornerShape(4.dp)
// )
// ) // )
// } // }
// } // })
// ) //
// Text( // Text(
// text = stringResource(R.string.band_label, i + 1), // text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp, // fontSize = 12.sp,
@@ -640,7 +634,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// } // }
// } // }
// } // }
} // }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -666,6 +661,9 @@ private fun DropdownMenuComponent(
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Column( Column(
@@ -681,13 +679,13 @@ private fun DropdownMenuComponent(
} else Modifier } else Modifier
) )
.background( .background(
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent, if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
0xFFFFFFFF
)) else Color.Transparent,
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) )
then(
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
)
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -709,8 +707,7 @@ private fun DropdownMenuComponent(
} }
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(onDragStart = { offset ->
onDragStart = { offset ->
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
touchOffset = offset touchOffset = offset
if (!expanded && now - lastDismissTime > 250L) { if (!expanded && now - lastDismissTime > 250L) {
@@ -719,15 +716,21 @@ private fun DropdownMenuComponent(
lastDismissTime = now lastDismissTime = now
parentDragActive = true parentDragActive = true
parentHoveredIndex = 0 parentHoveredIndex = 0
}, }, onDrag = { change, _ ->
onDrag = { change, _ ->
val current = change.position val current = change.position
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
parentHoveredIndex = idx parentHoveredIndex = idx
}, previousIdx = idx
onDragEnd = { }, onDragEnd = {
parentDragActive = false parentDragActive = false
parentHoveredIndex?.let { idx -> parentHoveredIndex?.let { idx ->
if (idx in options.indices) { if (idx in options.indices) {
@@ -736,13 +739,18 @@ private fun DropdownMenuComponent(
lastDismissTime = System.currentTimeMillis() lastDismissTime = System.currentTimeMillis()
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
}
parentHoveredIndex = null parentHoveredIndex = null
}, }, onDragCancel = {
onDragCancel = {
parentDragActive = false parentDragActive = false
parentHoveredIndex = null parentHoveredIndex = null
} })
)
}, },
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -758,42 +766,35 @@ private fun DropdownMenuComponent(
) )
if (!independent && description != null) { if (!independent && description != null) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
) )
} }
} }
Box( Box(
modifier = Modifier.onGloballyPositioned { coordinates -> modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent() boxPosition = coordinates.positionInParent()
} }) {
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = selectedOption, text = selectedOption, style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f), color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
Text( Text(
text = "􀆏", text = "􀆏", style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(start = 6.dp)
modifier = Modifier
.padding(start = 6.dp)
) )
} }
@@ -823,14 +824,17 @@ private fun DropdownMenuComponent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
)
) { ) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )

View File

@@ -16,86 +16,50 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.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 androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AdaptiveStrengthScreen(navController: NavController) { fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val state by viewModel.uiState.collectAsState()
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) {
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
sliderValue.floatValue = (100 - it)
}
}
}
}
}
DisposableEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
}
}
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
@@ -109,17 +73,43 @@ fun AdaptiveStrengthScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider( StyledSlider(
label = stringResource(R.string.customize_adaptive_audio), label = stringResource(R.string.customize_adaptive_audio),
mutableFloatState = sliderValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
sliderValue.floatValue = it sliderValue.floatValue = it
debounceJob?.cancel() job?.cancel()
debounceJob = CoroutineScope(Dispatchers.Default).launch { job = scope.launch {
delay(300) delay(150)
service.aacpManager.sendControlCommand( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
(100 - it).toInt() byteArrayOf((100 - it).toInt().toByte())
) )
} }
}, },
@@ -128,7 +118,8 @@ fun AdaptiveStrengthScreen(navController: NavController) {
startIcon = "􀊥", startIcon = "􀊥",
endIcon = "􀊩", endIcon = "􀊩",
independent = true, independent = true,
description = stringResource(R.string.adaptive_audio_description) description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
) )
} }
} }

View File

@@ -0,0 +1,574 @@
/*
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.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsPro3
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.AboutCard
import me.kavishdevar.librepods.presentation.components.AudioSettings
import me.kavishdevar.librepods.presentation.components.BatteryView
import me.kavishdevar.librepods.presentation.components.CallControlSettings
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable
fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var deviceName by remember {
mutableStateOf(
TextFieldValue(
sharedPreferences.getString("name", state.deviceName).toString()
)
)
}
val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") {
deviceName =
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
}
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
}
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.refreshInitialData()
}
val hazeStateS = remember { mutableStateOf(HazeState()) }
StyledScaffold(
title = deviceName.text, actionButtons = listOf(
{ scaffoldBackdrop ->
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}), snackbarHostState = snackbarHostState
) { topPadding, hazeState, bottomPadding ->
hazeStateS.value = hazeState
var blockTouches by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.demoActivated.collect {
blockTouches = true
delay(1000)
blockTouches = false
}
}
if (state.isLocallyConnected) {
val capabilities = state.capabilities
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
.then(if (blockTouches) Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "battery") {
BatteryView(
batteryList = state.battery,
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
)
}
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "name") {
NavigationButton(
to = "rename",
name = stringResource(R.string.name),
currentState = deviceName.text,
navController = navController,
independent = true
)
}
val hasHearingAidCapability =
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
val hasPPECapability =
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
if (hasHearingAidCapability || hasPPECapability) {
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
key = "spacer_hearing_health"
) { Spacer(modifier = Modifier.height(24.dp)) }
item(key = "hearing_health") {
HearingHealthSettings(
navController = navController,
hasPPECapability = hasPPECapability,
hasHearingAidCapability = hasHearingAidCapability,
vendorIdHook = state.vendorIdHook
)
}
}
if (capabilities.contains(Capability.LISTENING_MODE)) {
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "noise_control") {
NoiseControlSettings(
showOffListeningMode = state.offListeningMode,
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
0
)?.toInt() ?: 3,
onNoiseControlModeChanged = {
viewModel.setControlCommandInt(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
it
)
},
)
}
}
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") {
PressAndHoldSettings(
navController = navController,
leftAction = state.leftAction,
rightAction = state.rightAction
)
}
}
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") {
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try { bytes[1] == 0x02.toByte() } catch (e: Exception) { false }
CallControlSettings(
hazeState = hazeState,
flipped = flipped,
onCallControlValueChanged = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
)
})
}
// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
// item(key = "camera_control") {
// NavigationButton(
// to = "camera_control",
// name = stringResource(R.string.camera_remote),
// description = stringResource(R.string.camera_control_description),
// title = stringResource(R.string.camera_control),
// navController = navController
// )
// }
// }
item(key = "upgrade_button") {
if (!state.isPremium) {
Spacer(modifier = Modifier.height(28.dp))
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900
)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") {
val model = state.instance?.model ?: AirPodsPro3()
val adaptiveVolumeCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val conversationalAwarenessCapability =
model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
val loudSoundReductionCapability =
model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
val adaptiveAudioCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val adaptiveVolumeChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
val conversationalAwarenessChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
AudioSettings(
navController = navController,
adaptiveVolumeCapability = adaptiveVolumeCapability,
conversationalAwarenessCapability = conversationalAwarenessCapability,
loudSoundReductionCapability = loudSoundReductionCapability,
adaptiveAudioCapability = adaptiveAudioCapability,
adaptiveVolumeChecked = adaptiveVolumeChecked,
onAdaptiveVolumeCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
checked
)
},
conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
onConversationalAwarenessCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
checked
)
},
loudSoundReductionChecked = state.loudSoundReductionEnabled,
onLoudSoundReductionCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
)
},
vendorIdHook = state.vendorIdHook,
isPremium = state.isPremium
)
}
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") {
ConnectionSettings(
automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
onAutomaticEarDetectionChanged = {
viewModel.setAutomaticEarDetectionEnabled(it)
},
automaticConnectionEnabled = state.automaticConnectionEnabled,
onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) })
}
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") {
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
MicrophoneSettings(
hazeState = hazeState,
micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
}
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") {
val id =
AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
StyledToggle(
label = stringResource(R.string.sleep_detection),
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(id, it)
},
enabled = state.isPremium
)
}
}
if (capabilities.contains(Capability.HEAD_GESTURES)) {
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "head_tracking") {
NavigationButton(
to = "head_tracking",
name = stringResource(R.string.head_gestures),
navController = navController,
currentState = if (sharedPreferences.getBoolean(
"head_gestures", false
)
) stringResource(R.string.on) else stringResource(R.string.off)
)
}
}
item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "dynamic_end_of_charge") {
StyledToggle(
label = stringResource(R.string.optimized_charging),
description = stringResource(R.string.optimized_charging_description),
checked = state.dynamicEndOfCharge,
onCheckedChange = viewModel::setDynamicEndOfCharge
)
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") {
NavigationButton(
to = "accessibility",
name = stringResource(R.string.accessibility),
navController = navController
)
}
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") {
val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
StyledToggle(
label = stringResource(R.string.off_listening_mode),
description = stringResource(R.string.off_listening_mode_description),
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = viewModel::setOffListeningMode
)
}
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "about") {
AboutCard(
navController = navController,
modelName = state.modelName,
actualModel = state.actualModel,
serialNumbers = state.serialNumbers,
version = state.version3,
)
}
item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
item(key = "disconnect_button") {
StyledButton(
onClick = viewModel::disconnect,
backdrop = rememberLayerBackdrop(),
isInteractive = false,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
) {
Text(
text = stringResource(R.string.disconnect),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
}
} else {
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxSize()
.drawBackdrop(
backdrop = rememberLayerBackdrop(),
exportedBackdrop = backdrop,
shape = { RoundedCornerShape(0.dp) },
highlight = {
Highlight.Ambient.copy(alpha = 0f)
},
effects = {})
.hazeSource(hazeState)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Column(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = {
val now = System.currentTimeMillis()
if (now - lastTapTime.longValue > 400) {
tapCount.intValue = 0
}
tapCount.intValue++
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
viewModel.activateDemoMode()
}
})
}) {
Text(
text = stringResource(R.string.airpods_not_connected), style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.airpods_not_connected_description),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(32.dp))
if (!BuildConfig.PLAY_BUILD) {
StyledButton(
onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.troubleshooting),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
}
if (state.connectionSuccessful) {
StyledButton(
onClick = {
viewModel.reconnectFromSavedMac()
}, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,729 @@
/*
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.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.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.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.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 androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.NavigationButton
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.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
) {
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() }
StyledScaffold(
title = stringResource(R.string.settings)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
if (!state.isPremium && state.connectionSuccessful) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
Text(
text = stringResource(R.string.popup_animations), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.show_bottom_sheet_popup),
description = stringResource(R.string.show_bottom_sheet_popup_description),
checked = state.showBottomSheetPopup,
onCheckedChange = viewModel::setShowBottomSheetPopup,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.show_island_popup),
description = stringResource(R.string.show_island_popup_description),
checked = state.showIslandPopup,
onCheckedChange = viewModel::setShowIslandPopup,
independent = false
)
}
Text(
text = stringResource(R.string.conversational_awareness), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true,
enabled = state.isPremium
)
// if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp))
//
// NavigationButton(
// to = "",
// title = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package),
// navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// description = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
)
}
Text(
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false,
enabled = state.isPremium
)
}
Text(
text = stringResource(R.string.advanced_options), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true,
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.customizations_unavailable),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier
)
}
}
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)
},
independent = true,
enabled = state.isPremium
)
}
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "troubleshooting",
name = stringResource(R.string.troubleshooting),
navController = navController,
independent = true,
description = stringResource(R.string.troubleshooting_description)
)
}
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.contact), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "",
name = stringResource(R.string.email),
navController = navController,
onClick = { contactBottomSheet.value = true },
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.discord),
navController = navController,
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.github_issues),
navController = navController,
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/kavishdevar/librepods/issues".toUri()
)
context.startActivity(intent)
},
independent = false
)
}
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard()
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "open_source_licenses",
name = stringResource(R.string.open_source_licenses),
navController = navController,
independent = true
)
Spacer(modifier = Modifier.height(bottomPadding))
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}, text = {
Column {
Text(
stringResource(R.string.enter_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
0xFF3C6DF5
),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (state.cameraPackageError != null) {
Text(
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
label = { Text(stringResource(R.string.custom_camera_package)) })
}
}, confirmButton = {
val successText = stringResource(R.string.custom_camera_package_set_success)
TextButton(
onClick = {
viewModel.saveCameraPackage()
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
}) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}, dismissButton = {
TextButton(
onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
})
}
}
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),
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false
)
}
}
}
}

View File

@@ -0,0 +1,111 @@
/*
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.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun CameraControlScreen(viewModel: AirPodsViewModel) {
val context = LocalContext.current
val currentCameraAction by viewModel.cameraAction.collectAsState()
fun isAppListenerServiceEnabled(context: Context): Boolean {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices =
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val serviceComponent = ComponentName(context, AppListenerService::class.java)
return enabledServices.any {
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
it.resolveInfo.serviceInfo.name == serviceComponent.className
}
}
fun handleSelection(action: StemPressType?) {
if (action != null && !isAppListenerServiceEnabled(context)) {
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else {
viewModel.setCameraAction(action)
}
}
val cameraOptions = remember(currentCameraAction) {
listOf(
SelectItem(
name = "Off",
selected = currentCameraAction == null,
onClick = { handleSelection(null) }
),
SelectItem(
name = "Press once",
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
),
SelectItem(
name = "Press and hold AirPods",
selected = currentCameraAction == StemPressType.LONG_PRESS,
onClick = { handleSelection(StemPressType.LONG_PRESS) }
)
)
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.camera_control)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledSelectList(items = cameraOptions)
}
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.data.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -335,12 +335,11 @@ fun DebugScreen(navController: NavController) {
expandedItems.value = emptySet() expandedItems.value = emptySet()
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -349,7 +348,7 @@ fun DebugScreen(navController: NavController) {
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier

View File

@@ -21,12 +21,13 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.graphics.Paint
import android.os.Build import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -45,6 +46,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -74,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText import androidx.compose.ui.text.drawText
@@ -83,9 +84,9 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -94,10 +95,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -107,14 +109,14 @@ import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen() { fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
onDispose { onDispose {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
} }
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -127,34 +129,35 @@ fun HeadTrackingScreen() {
title = stringResource(R.string.head_tracking), title = stringResource(R.string.head_tracking),
actionButtons = listOf( actionButtons = listOf(
{ scaffoldBackdrop -> { scaffoldBackdrop ->
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton( StyledIconButton(
onClick = { onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) { if (!state.headTrackingActive) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started") Log.d("HeadTrackingScreen", "Head tracking started")
} else { } else {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped") Log.d("HeadTrackingScreen", "Head tracking stopped")
} }
}, },
icon = if (isActive) "􀊅" else "􀊃", icon = if (state.headTrackingActive) "􀊅" else "􀊃",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { spacerHeight, hazeState -> ) { topPadding, hazeState, _ ->
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) } var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) } var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth()
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Column ( Column (
@@ -164,37 +167,50 @@ fun HeadTrackingScreen() {
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(scrollState)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
StyledToggle( StyledToggle(
label = "Head Gestures", label = "Head Gestures",
sharedPreferences = sharedPreferences, checked = state.headGesturesEnabled,
sharedPreferenceKey = "head_gestures", onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
) enabled = state.isPremium || state.headGesturesEnabled,
description = stringResource(R.string.head_gestures_details)
Spacer(modifier = Modifier.height(2.dp))
Text(
stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(0.6f)
),
modifier = Modifier.padding(start = 4.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Head Orientation", "Head Orientation",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
) )
HeadVisualization() HeadVisualization()
@@ -202,12 +218,12 @@ fun HeadTrackingScreen() {
Text( Text(
"Velocity", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
) )
AccelerationPlot() AccelerationPlot()
@@ -383,7 +399,8 @@ private fun HeadVisualization() {
.aspectRatio(2f), .aspectRatio(2f),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = backgroundColor containerColor = backgroundColor
) ),
shape = RoundedCornerShape(28.dp)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -458,9 +475,9 @@ private fun HeadVisualization() {
spherePath.close() spherePath.close()
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.3f, center.x + sinY * faceRadius * 0.3f,
center.y - sinP * faceRadius * 0.3f, center.y - sinP * faceRadius * 0.3f,
faceRadius * 1.4f, faceRadius * 1.4f,
@@ -472,14 +489,14 @@ private fun HeadVisualization() {
backgroundColor.copy(alpha = 0.7f).toArgb() backgroundColor.copy(alpha = 0.7f).toArgb()
), ),
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f), floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
} }
drawPath(spherePath.asAndroidPath(), paint) drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = android.graphics.Paint().apply { val highlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f, center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f, center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
faceRadius * 0.9f, faceRadius * 0.9f,
@@ -489,15 +506,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 0.3f, 1f), floatArrayOf(0f, 0.3f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 30 else 60 alpha = if (darkTheme) 30 else 60
} }
drawPath(spherePath.asAndroidPath(), highlightPaint) drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = android.graphics.Paint().apply { val secondaryHighlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f, center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f, center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
faceRadius * 0.7f, faceRadius * 0.7f,
@@ -506,15 +523,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 1f), floatArrayOf(0f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 15 else 30 alpha = if (darkTheme) 15 else 30
} }
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint) drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = android.graphics.Paint().apply { val shadowPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.5f, center.x + sinY * faceRadius * 0.5f,
center.y - sinP * faceRadius * 0.5f, center.y - sinP * faceRadius * 0.5f,
faceRadius * 1.1f, faceRadius * 1.1f,
@@ -523,7 +540,7 @@ private fun HeadVisualization() {
android.graphics.Color.BLACK android.graphics.Color.BLACK
), ),
floatArrayOf(0.7f, 1f), floatArrayOf(0.7f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 40 else 20 alpha = if (darkTheme) 40 else 20
} }
@@ -583,13 +600,13 @@ private fun HeadVisualization() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
typeface = android.graphics.Typeface.create( typeface = Typeface.create(
"SF Pro", "SF Pro",
android.graphics.Typeface.NORMAL Typeface.NORMAL
) )
} }
@@ -634,7 +651,8 @@ private fun AccelerationPlot() {
.height(300.dp), .height(300.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
) ),
shape = RoundedCornerShape(28.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -702,10 +720,10 @@ private fun AccelerationPlot() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
} }
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
@@ -718,20 +736,20 @@ private fun AccelerationPlot() {
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
} }
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
} }
@@ -739,11 +757,3 @@ private fun AccelerationPlot() {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable
fun HeadTrackingScreenPreview() {
HeadTrackingScreen()
}

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
@@ -50,15 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme() isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments) title = stringResource(R.string.adjustments)
@@ -125,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
) )
} }
val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
val hearingAidATTListener = remember { val hearingAidATTListener = remember {
object : (ByteArray) -> Unit { object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) { override fun invoke(value: ByteArray) {
@@ -165,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
} }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) { LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
@@ -256,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -268,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description) description = stringResource(R.string.swipe_amplification_description)
) )
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -288,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -300,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -311,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description)
) )

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -61,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.utils.sendTransparencySettings
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings" private const val TAG = "AccessibilitySettings"
@@ -78,23 +78,22 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(navController: NavController) { fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) } val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember { val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
@@ -102,7 +101,7 @@ fun HearingAidScreen(navController: NavController) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_aid), title = stringResource(R.string.hearing_aid),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -113,43 +112,18 @@ fun HearingAidScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
// val mediaAssistEnabled = remember { mutableStateOf(false) } // val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) } // val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) } // val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
LaunchedEffect(hearingAidEnabled.value) { LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) { if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) { } else if (!hearingAidEnabled.value && !initialLoad.value) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
} }
initialLoad.value = false initialLoad.value = false
@@ -186,7 +160,8 @@ fun HearingAidScreen(navController: NavController) {
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.hearing_aid), label = stringResource(R.string.hearing_aid),
checkedState = hearingAidEnabled, checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
independent = false independent = false
) )
HorizontalDivider( HorizontalDivider(
@@ -198,7 +173,7 @@ fun HearingAidScreen(navController: NavController) {
NavigationButton( NavigationButton(
to = "hearing_aid_adjustments", to = "hearing_aid_adjustments",
name = stringResource(R.string.adjustments), name = stringResource(R.string.adjustments),
navController, navController = navController,
independent = false independent = false
) )
} }
@@ -217,7 +192,7 @@ fun HearingAidScreen(navController: NavController) {
NavigationButton( NavigationButton(
to = "update_hearing_test", to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test), name = stringResource(R.string.update_hearing_test),
navController, navController = navController,
independent = true independent = true
) )
@@ -258,9 +233,9 @@ fun HearingAidScreen(navController: NavController) {
// independent = false // independent = false
// ) // )
// } // }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Enable Hearing Aid", title = "Enable Hearing Aid",
@@ -269,26 +244,33 @@ fun HearingAidScreen(navController: NavController) {
dismissText = "Cancel", dismissText = "Cancel",
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte() val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) { if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else { } else {
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} }
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) if (state.hearingAidData.isEmpty()) {
val parsed = parseTransparencySettingsResponse(data) Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(attManager, disabledSettings) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}") Log.e(TAG, "Error disabling transparency: ${e.message}")
} }
} }
}, },
hazeState = hazeStateS.value, onDismiss = {
// backdrop = backdrop hearingAidEnabled.value = false
showDialog.value = false
},
// hazeState = hazeStateS.value,
backdrop = backdrop
) )
} }

View File

@@ -0,0 +1,120 @@
/*
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
}
}
}

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current val context = LocalContext.current
val libraries by produceLibraries { val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries) context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader() .bufferedReader()

View File

@@ -0,0 +1,211 @@
/*
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(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = name
) { spacerHeight ->
Column (
modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize()
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val actionItems = listOf(
SelectItem(
name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES)
}
),
SelectItem(
name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT)
},
enabled = state.isPremium
)
)
StyledSelectList(items = actionItems)
if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(horizontal = 18.dp)
)
Spacer(modifier = Modifier.height(8.dp))
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val listeningModeItems = mutableListOf<SelectItem>()
if (state.offListeningMode) {
listeningModeItems.add(
SelectItem(
name = stringResource(R.string.off),
description = stringResource(R.string.listening_mode_off_description),
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0,
onClick = {
viewModel.toggleListeningMode(0x01)
}
)
)
}
listeningModeItems.addAll(listOf(
SelectItem(
name = stringResource(R.string.transparency),
description = stringResource(R.string.listening_mode_transparency_description),
iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0,
onClick = {
viewModel.toggleListeningMode(0x04)
}
),
SelectItem(
name = stringResource(R.string.adaptive),
description = stringResource(R.string.listening_mode_adaptive_description),
iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0,
onClick = {
viewModel.toggleListeningMode(0x08)
}
),
SelectItem(
name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_noise_cancellation_description),
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0,
onClick = {
viewModel.toggleListeningMode(0x02)
}
)
))
StyledSelectList(items = listeningModeItems)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.press_and_hold_noise_control_description),
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 18.dp)
)
}
}
}
}

View File

@@ -0,0 +1,528 @@
/*
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@Composable
fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(),
navController: NavController
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.unlock_advanced_features)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
LaunchedEffect(state.isPremium) {
if (state.isPremium) {
navController.popBackStack()
}
}
if (!state.isPremium) {
Box(
modifier = Modifier
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.free_features),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.ear_detection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.ear_detection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.battery),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.battery_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.noise_control_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
if (BuildConfig.FLAVOR == "xposed") {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
R.string.requires_xposed
) + ")",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.advanced_features),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.conversational_awareness_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.head_gestures),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.advanced_device_settings),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.advanced_device_settings_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.automatic_connection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.automatic_connection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.customizations),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.customizations_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.support_the_development),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.support_development_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.feature_availability_disclaimer),
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
textAlign = TextAlign.Center
),
)
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.buy_price, state.price),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledButton(
onClick = {
viewModel.restorePurchases()
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
isInteractive = false
) {
Text(
stringResource(R.string.restore_purchases),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}

View File

@@ -0,0 +1,84 @@
/*
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.screens
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
StyledScaffold(
title = stringResource(R.string.name),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val textFieldState = rememberTextFieldState()
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
}
StyledInputField(
textFieldState,
focusRequester
)
}
}
}

View File

@@ -16,8 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -41,8 +42,9 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -58,23 +60,20 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -84,14 +83,10 @@ private const val TAG = "TransparencySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun TransparencySettingsScreen(navController: NavController) { fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable =
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -99,9 +94,12 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_transparency_mode) title = stringResource(R.string.customize_transparency_mode)
){ spacerHeight, hazeState -> ){ topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -111,7 +109,7 @@ fun TransparencySettingsScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) } val enabled = remember { mutableStateOf(false) }
@@ -148,23 +146,6 @@ fun TransparencySettingsScreen(navController: NavController) {
) )
} }
val transparencyListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseTransparencySettingsResponse(value)
enabled.value = parsed.enabled
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
}
}
}
LaunchedEffect( LaunchedEffect(
enabled.value, enabled.value,
amplificationSliderValue.floatValue, amplificationSliderValue.floatValue,
@@ -205,26 +186,16 @@ fun TransparencySettingsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue balance = balanceSliderValue.floatValue
) )
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(attManager, transparencySettings.value) sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...") Log.d(TAG, "Connecting to ATT...")
try { try {
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
// If we have an AACP manager, prefer its EQ data to populate EQ controls first // If we have an AACP manager, prefer its EQ data to populate EQ controls first
try { try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data") Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) { if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf() eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf() phoneMediaEQ.value = aacpEQ.copyOf()
@@ -232,9 +203,6 @@ fun TransparencySettingsScreen(navController: NavController) {
} else { } else {
Log.d(TAG, "AACPManager EQ data empty") Log.d(TAG, "AACPManager EQ data empty")
} }
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
} }
@@ -243,7 +211,7 @@ fun TransparencySettingsScreen(navController: NavController) {
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data) parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt") Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) { } catch (e: Exception) {
@@ -276,19 +244,19 @@ fun TransparencySettingsScreen(navController: NavController) {
} }
} }
// Only show transparency mode section if SDP offset is available if (state.vendorIdHook) {
if (isSdpOffsetAvailable.value) {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checkedState = enabled, checked = enabled.value,
independent = true, independent = true,
description = stringResource(R.string.customize_transparency_mode_description) description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it }
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -300,7 +268,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -313,7 +281,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -325,7 +293,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -336,14 +304,12 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it }
) )
}
// Only show transparency mode EQ section if SDP offset is available
if (isSdpOffsetAvailable.value) {
Text( Text(
text = stringResource(R.string.equalizer), text = stringResource(R.string.equalizer),
style = TextStyle( style = TextStyle(
@@ -443,6 +409,8 @@ fun TransparencySettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -216,7 +216,7 @@ fun TroubleshootingScreen(navController: NavController) {
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.troubleshooting) title = stringResource(R.string.troubleshooting)
){ spacerHeight, hazeState -> ){ topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -225,7 +225,7 @@ fun TroubleshootingScreen(navController: NavController) {
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
Text( Text(
text = stringResource(R.string.saved_logs), text = stringResource(R.string.saved_logs),

View File

@@ -16,9 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -33,7 +32,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -55,31 +53,25 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) { fun UpdateHearingTestScreen() {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager val attManager = ServiceManager.getService()?.attManager
if (attManager == null) { if (attManager == null) {
@@ -96,7 +88,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_test) title = stringResource(R.string.hearing_test)
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -108,7 +100,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
) { ) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
Text( Text(
text = stringResource(R.string.hearing_test_value_instruction), text = stringResource(R.string.hearing_test_value_instruction),
@@ -138,17 +130,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value, leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.value, rightAmplification = rightAmplification.floatValue,
leftTone = tone.value, leftTone = tone.floatValue,
rightTone = tone.value, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.value, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.value + rightAmplification.value / 2, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.value ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
) )
} }
@@ -161,11 +153,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf() rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost conversationBoostEnabled.value = parsed.leftConversationBoost
tone.value = parsed.leftTone tone.floatValue = parsed.leftTone
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.value = parsed.leftAmplification leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.value = parsed.rightAmplification rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
Log.w(TAG, "Failed to parse hearing aid settings from notification") Log.w(TAG, "Failed to parse hearing aid settings from notification")
@@ -181,31 +173,45 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) { LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect return@LaunchedEffect
} }
if (!initialReadSucceeded.value) { if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect return@LaunchedEffect
} }
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value, leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.value, rightAmplification = rightAmplification.floatValue,
leftTone = tone.value, leftTone = tone.floatValue,
rightTone = tone.value, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.value, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.value + rightAmplification.value / 2, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.value ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
@@ -240,14 +246,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsedSettings.leftEQ.copyOf() leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf() rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.value = parsedSettings.leftTone tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.value = parsedSettings.leftAmplification leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.value = parsedSettings.rightAmplification rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
} }
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -256,7 +265,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") val frequencies =
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -342,6 +352,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }

View File

@@ -16,25 +16,25 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -45,36 +45,23 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun VersionScreen(navController: NavController) { fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio) title = stringResource(R.string.version)
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -93,7 +80,8 @@ fun VersionScreen(navController: NavController) {
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -120,7 +108,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version1 ?: "N/A", text = state.version1,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -149,7 +137,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version2 ?: "N/A", text = state.version2,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -178,7 +166,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version3 ?: "N/A", text = state.version3,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),

View File

@@ -17,7 +17,7 @@
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.presentation.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color

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