mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-25 13:35:48 +02:00
Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f9274145 | |||
| a68637ed7c | |||
| e950aa010d | |||
| ddd1a9c5ce | |||
| 8457880210 | |||
| b5a3eaee8f | |||
| c3f537b0e9 | |||
| e6ebbed674 | |||
| e34474f765 | |||
| 4f910136dc | |||
| 721819e792 | |||
| bf3920718c | |||
| cb0c46dc33 | |||
| cd40975a1f | |||
| 790e396345 | |||
| 633d036dd7 | |||
| 7341e41837 | |||
| bffb5c8b3e | |||
| aca4373ec4 | |||
| 8804197760 | |||
| 57d692c4ae | |||
| 0477674810 | |||
| c1093fbe24 | |||
| 0f50eab788 | |||
| 1381022b2e | |||
| af4261485a | |||
| 571db0ebde | |||
| 3c3c0edffd | |||
| f86d7b9aca | |||
| 29a914c2ff | |||
| 3f2a7df749 | |||
| f9367f4445 | |||
| 0101428108 | |||
| 37c1837d0b | |||
| 154ed17c05 | |||
| 362e34202a | |||
| 5bc5079e13 | |||
| 64d233d427 | |||
| ea2c2b811b | |||
| 6f28df734e | |||
| c15e15a6b7 | |||
| 75a52cdfd7 | |||
| d1b32d5a00 | |||
| 044aff731f | |||
| 216c97f9ca | |||
| fd3774b513 | |||
| b7336940e6 | |||
| b2ba830a80 | |||
| f08769e62f | |||
| d1933c3b67 | |||
| fb44f01ac0 | |||
| 93a93cbe68 | |||
| a4898293b8 | |||
| 845f26192c | |||
| 3321bb1c43 | |||
| c7a5cb2d8c | |||
| 7b81411417 | |||
| d80f2275a1 | |||
| 795bebc6ae | |||
| 4ef3e4d4da | |||
| b88b14de15 | |||
| 7cd4dfa3e0 | |||
| 30d16e9977 | |||
| ddcf15eefe | |||
| 3e89d7f41b | |||
| 9eb6010a25 | |||
| 60e865fc1f | |||
| 629b7b917e | |||
| d4ee741224 | |||
| 4c8b0d720d | |||
| e20b0f7fd7 | |||
| b64ff1d09e | |||
| 37056c6de7 | |||
| 3a636e37a4 | |||
| 136e3e8995 | |||
| e39c1cfeba | |||
| b06d780eee | |||
| 70f420dedb | |||
| 23193ceb39 | |||
| cb246d1287 | |||
| 95cd677da9 | |||
| 0d049d93fb | |||
| 469d948061 | |||
| f5d92768e2 | |||
| 8cb2951bc6 | |||
| bb578dab23 | |||
| b1b47048a3 | |||
| bf09300dfe | |||
| 70165232c0 | |||
| aabbc902cb | |||
| 0ee7056600 | |||
| 8b24ac49e2 | |||
| d2dd722bc7 | |||
| 67fc93bde5 | |||
| 0b578d62cf | |||
| 072b9b4dac | |||
| 0af60cd8a9 | |||
| be29a46dab | |||
| 7461f7dfb7 | |||
| 904c00afce | |||
| 6272357d84 | |||
| 6ac6700be6 | |||
| 113ee0a966 | |||
| d82e4e2427 | |||
| 481d5f13cf | |||
| ef221af505 | |||
| c19190f031 | |||
| d0b8574c68 | |||
| 294d733e71 | |||
| f6d7e97796 | |||
| ae174bc9ea | |||
| 1804e80cba | |||
| 0b8bd5a5b8 | |||
| d1d48562d7 | |||
| c84e64e656 | |||
| 51739514fa | |||
| 8f9a04390a | |||
| 2363b80548 | |||
| 99616d6a67 | |||
| 4f2d11a4b2 | |||
| 1f2d70744f | |||
| 3c714c2a26 | |||
| 7fbbe07320 | |||
| 8ef839d4c1 | |||
| af2622b68e | |||
| b81e420398 | |||
| fd33528218 |
@@ -0,0 +1,110 @@
|
||||
name: Bug report (Android)
|
||||
description: Report a bug in the Android app
|
||||
labels: ["bug", "android"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill in as much as you can.
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App version
|
||||
description: "Find this in `Settings → About → Version` in the app, or in your phone's app info."
|
||||
placeholder: "v0.2.5 (build 46)"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-source
|
||||
attributes:
|
||||
label: App source
|
||||
options:
|
||||
- GitHub
|
||||
- Play
|
||||
- Built from source
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device
|
||||
description: Manufacturer and model.
|
||||
placeholder: "Google Pixel 8 Pro"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: android-version
|
||||
attributes:
|
||||
label: Android / OS version
|
||||
description: Include the OEM skin if relevant.
|
||||
placeholder: "Android 16, OxygenOS 16, ColorOS 16, ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: root-method
|
||||
attributes:
|
||||
label: Root / hook method
|
||||
options:
|
||||
- No root (native L2CAP support)
|
||||
- Magisk + Xposed
|
||||
- KernelSU + Xposed
|
||||
- Other (describe in additional context)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: airpods-model
|
||||
attributes:
|
||||
label: AirPods model
|
||||
options:
|
||||
- AirPods (1st gen)
|
||||
- AirPods (2nd gen)
|
||||
- AirPods (3rd gen)
|
||||
- AirPods (4th gen)
|
||||
- AirPods (4th gen) with ANC
|
||||
- AirPods Pro (1st gen)
|
||||
- AirPods Pro 2 (Lightning)
|
||||
- AirPods Pro 2 (USB-C)
|
||||
- AirPods Pro 3
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: firmware
|
||||
attributes:
|
||||
label: AirPods firmware
|
||||
description: Find this under `About` in the app once connected.
|
||||
placeholder: "8454768"
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
If you are rooted, give the app root access, open the app, go to `Settings → Troubleshooting → Collect Logs`, and attach the resulting file here.
|
||||
|
||||
Without logs most bugs are very hard to diagnose. If you are not, follow these instructions:
|
||||
(Needs access to a computer, and USB/Wireless Debugging under developer options enabled)
|
||||
|
||||
Commands:
|
||||
- Get the uid: Linux/Mac: `adb shell dumpsys package me.kavishdevar.librepods | grep uid`
|
||||
- Start logs: `adb logcat --uid=<uid>,1002 >> librepods-logs.txt` (1002 is for bluetooth)
|
||||
|
||||
Steps for proper logs
|
||||
- force close the app
|
||||
- turn off bluetooth
|
||||
- start logs
|
||||
- open the app
|
||||
- turn on bluetooth and connect
|
||||
|
||||
placeholder: Paste log content or attach the file
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else that might help (screenshots, video, related issues, what you've already tried).
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Bug report (Linux)
|
||||
description: Report a bug in the Linux program
|
||||
labels: ["bug", "linux"]
|
||||
title: "[Linux] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the report. Please fill in as much as you can.
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App version
|
||||
placeholder: "linux-v0.1.0, or linux-rust commit abc1234"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Variant
|
||||
options:
|
||||
- Rust rewrite (`linux-rust` branch)
|
||||
- QT version (NOT MAINTAINED! issues will be closed)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: distro
|
||||
attributes:
|
||||
label: Distro and version
|
||||
placeholder: "Arch Linux, Fedora 41, Ubuntu 24.04, NixOS 25.05"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: desktop
|
||||
attributes:
|
||||
label: Desktop environment / compositor
|
||||
placeholder: "GNOME 47 (Wayland), KDE 6 (X11), Hyprland, ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
label: Install method (only official sources)
|
||||
options:
|
||||
- Built from source (`nix` or otherwise)
|
||||
- Pre-built binary
|
||||
- AppImage
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: airpods-model
|
||||
attributes:
|
||||
label: AirPods model
|
||||
options:
|
||||
- AirPods (1st gen)
|
||||
- AirPods (2nd gen)
|
||||
- AirPods (3rd gen)
|
||||
- AirPods (4th gen)
|
||||
- AirPods (4th gen) with ANC
|
||||
- AirPods Pro (1st gen)
|
||||
- AirPods Pro 2 (Lightning)
|
||||
- AirPods Pro 2 (USB-C)
|
||||
- AirPods Pro 3
|
||||
- Other / not sure
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and stderr
|
||||
description: Run the app from a terminal with `--debug` and paste the output.
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Additional context
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Feature request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope
|
||||
options:
|
||||
- Android
|
||||
- Linux
|
||||
- Both
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or use case
|
||||
description: What are you trying to do? What is missing or hard today?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How might it work? UI sketches, behavior, edge cases.
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
@@ -1,100 +1,212 @@
|
||||
name: Build APK and root module (and create nightly release)
|
||||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*'
|
||||
paths:
|
||||
- 'android/**'
|
||||
- 'root-module-manual/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'android/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: 'Create a nightly release'
|
||||
branch:
|
||||
description: Branch to build
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
custom_notes:
|
||||
description: 'Custom updates to add to What''s Changed section'
|
||||
required: false
|
||||
type: string
|
||||
default: main
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-debug-apk:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
short_sha: ${{ steps.vars.outputs.short_sha }}
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
distribution: zulu
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
- name: Decode keystore
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
- name: Accept Licenses
|
||||
run: yes | sdkmanager --licenses
|
||||
- name: Install NDK
|
||||
run: sdkmanager "ndk;30.0.14904198"
|
||||
- name: Create local.properties
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
cat <<EOF > android/local.properties
|
||||
RELEASE_STORE_FILE=../release.keystore
|
||||
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
EOF
|
||||
- name: Build debug APK for PRs
|
||||
if: github.event_name == 'pull_request'
|
||||
run: ./gradlew assembleFossDebug
|
||||
working-directory: android
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Build release artifacts
|
||||
if: github.event_name != 'pull_request'
|
||||
run: ./gradlew packageReleaseArtifacts
|
||||
working-directory: android
|
||||
- name: Get app version
|
||||
id: version
|
||||
run: echo "app_version=$(grep 'appVersionName =' android/app/build.gradle.kts | sed 's/.*= "\(.*\)"/\1/')" >> $GITHUB_OUTPUT
|
||||
- id: vars
|
||||
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: Debug APK
|
||||
path: android/app/build/outputs/apk/**/*.apk
|
||||
nightly-release:
|
||||
name: apk-release
|
||||
path: release/*release.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
name: apk-debug
|
||||
path: android/app/build/outputs/apk/foss/debug/app-foss-debug.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: apk-debug
|
||||
path: release/*debug.apk
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: root-module-release
|
||||
path: release/*release.zip
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: root-module-debug
|
||||
path: release/*debug.zip
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: release-bundle
|
||||
path: release/*.aab
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push'
|
||||
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-debug-apk
|
||||
needs: build
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/download-artifact@v4
|
||||
- name: Export APK_NAME for later use
|
||||
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
||||
- name: Rename .apk file
|
||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
||||
- name: Decode keystore file
|
||||
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
|
||||
- name: Install apksigner
|
||||
run: sudo apt-get update && sudo apt-get install -y apksigner
|
||||
- name: Sign APK
|
||||
run: |
|
||||
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
|
||||
- name: Verify APK
|
||||
run: apksigner verify "./$APK_NAME"
|
||||
- name: Fetch the latest non-nightly release tag
|
||||
id: fetch-tag
|
||||
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
|
||||
- name: Retrieve commits since the last release
|
||||
id: get-commits
|
||||
run: |
|
||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
||||
echo "::set-output name=commits::${COMMITS}"
|
||||
- name: Prepare release notes
|
||||
id: release-notes
|
||||
run: |
|
||||
# Create a temporary file for release notes
|
||||
NOTES_FILE=$(mktemp)
|
||||
|
||||
# Process custom notes if they exist
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
||||
|
||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
||||
# Already formatted, use as is
|
||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
else
|
||||
# Add bullet point formatting
|
||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
||||
- name: Zip root-module directory
|
||||
run: sh ./build-magisk-module.sh
|
||||
- name: Delete release if exist then create release
|
||||
with:
|
||||
name: apk-release
|
||||
path: artifacts/apk-release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: apk-debug
|
||||
path: artifacts/apk-debug
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: root-module-release
|
||||
path: artifacts/root-module-release
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: root-module-debug
|
||||
path: artifacts/root-module-debug
|
||||
- id: prev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
||||
TAG=$(gh release list \
|
||||
--limit 1 \
|
||||
--json tagName \
|
||||
--jq '.[0].tagName')
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- id: changelog
|
||||
run: |
|
||||
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
|
||||
NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
|
||||
else
|
||||
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
|
||||
fi
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- id: tag
|
||||
run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "${{ steps.tag.outputs.tag }}" \
|
||||
artifacts/**/* \
|
||||
-t "Nightly ${{ needs.build.outputs.short_sha }}" \
|
||||
--notes "${{ steps.changelog.outputs.notes }}" \
|
||||
--prerelease
|
||||
- name: Get timestamp
|
||||
id: timestamp
|
||||
run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||
- name: Post to Discord
|
||||
run: |
|
||||
curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "LibrePods Nightly Build",
|
||||
"description": "Download the latest debug and release APKs.",
|
||||
"color": 253060,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Changelog",
|
||||
"value": "${{ steps.changelog.outputs.notes }}",
|
||||
"inline": false
|
||||
}
|
||||
],
|
||||
"timestamp": "${{ steps.timestamp.outputs.timestamp }}",
|
||||
"footer": {
|
||||
"text": "GitHub Actions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"type": 1,
|
||||
"components": [
|
||||
{
|
||||
"type": 2,
|
||||
"label": "Download Release APK",
|
||||
"style": 5,
|
||||
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk"
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"label": "Download Debug APK",
|
||||
"style": 5,
|
||||
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Linux Build & Release
|
||||
name: Linux CI (Rust)
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
+1
-6
@@ -1,10 +1,5 @@
|
||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
||||
wak.toml
|
||||
log.txt
|
||||
btl2capfix.zip
|
||||
root-module-manual
|
||||
release
|
||||
.vscode
|
||||
testing.py
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -1,128 +1,234 @@
|
||||
>[!IMPORTANT]
|
||||
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
||||
|
||||

|
||||
|
||||
## What is LibrePods?
|
||||
|
||||
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
|
||||
|
||||
## Device Compatibility
|
||||
|
||||
| Status | Device | Features |
|
||||
| ------ | --------------------- | ---------------------------------------------------------- |
|
||||
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
|
||||
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
|
||||
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
|
||||
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
|
||||
|
||||
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS).
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
|
||||
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
|
||||
- **Battery Status**: Accurate battery levels
|
||||
- **Head Gestures**: Answer calls just by nodding your head
|
||||
- **Conversational Awareness**: Volume automatically lowers when you speak
|
||||
- **Hearing Aid\***
|
||||
- **Customize Transparency Mode\***
|
||||
- **Multi-device connectivity\*** (upto 2 devices)
|
||||
- **Other customizations**:
|
||||
- Rename your AirPods
|
||||
- Customize long-press actions
|
||||
- All accessibility settings
|
||||
- And more!
|
||||
|
||||
* Features marked with an asterisk require the VendorID to be change to that of Apple.
|
||||
|
||||
## Platform Support
|
||||
|
||||
### Linux
|
||||
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
|
||||
|
||||
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
|
||||
|
||||

|
||||
|
||||
### Android
|
||||
|
||||
#### Screenshots
|
||||
|
||||
| | | |
|
||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
|
||||
here's a very unprofessional demo video
|
||||
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
#### Root Requirement
|
||||
|
||||
If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
|
||||
|
||||
> [!CAUTION]
|
||||
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. DO NOT leave a +1 comment - use the +1 button in the top right of the page next to the "Hotlists" field. Leaving +1 comment spam makes it impossible for developers to engage in the necessary technical discussion to implement this fix, and will disincentivize the responsible Google developers from engaging. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+.
|
||||
> [!WARNING]
|
||||
> librepods.org is not an official website of the LibrePods project. It inaccurately claims to be the official website of the project by claiming copyrights and using the LibrePods logo in the footer. And at the same time, they say that the project is not affiliated with the LibrePods project or its developers.
|
||||
>
|
||||
> There are **no exceptions** to the root requirement until Google/your OEM figures out a fix.
|
||||
> Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz)
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./imgs/banner.png" />
|
||||
<img alt="LibrePods" src="./imgs/banner.png" />
|
||||
</picture>
|
||||
|
||||
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
|
||||
<div align="center" style="margin: 20px 0px;">
|
||||
<a href="https://github.com/kavishdevar/librepods/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?label=GitHub%20Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml">
|
||||
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml/badge.svg" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml">
|
||||
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml/badge.svg" />
|
||||
</a>
|
||||
<a href="https://github.com/kavishdevar/librepods/issues">
|
||||
<img src="https://img.shields.io/github/issues/kavishdevar/librepods" />
|
||||
</a>
|
||||
<a href="https://discord.gg/HhG4ycVum4">
|
||||
<img src="https://img.shields.io/discord/1441416992027574375?logoColor=white&color=5865F2&label=Discord" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Changing VendorID in the DID profile to that of Apple
|
||||
# What is LibrePods?
|
||||
|
||||
LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
|
||||
|
||||
# Feature availability
|
||||
|
||||
| Feature | Linux | Android |
|
||||
| ----------------------------------------------------------- | ----- | ------- |
|
||||
| Changing Listening Mode | ✅ | ✅ |
|
||||
| Ear detection | ✅ | ✅ |
|
||||
| Battery status | ✅ | ✅ |
|
||||
| Renaming AirPods <details><summary>Note for Android</summary>On Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.</details> | ✅ | ✅ |
|
||||
| Loud Sound Reduction | 🔴 | ⚪ |
|
||||
| Head Gestures | ⛔ | ✅ |
|
||||
| Conversational Awareness | ✅ | ✅ |
|
||||
| Automatically connect to AirPods | ✅ | ✅ |
|
||||
| Hearing Aid | 🔴 | ⚪ |
|
||||
| Transparency Mode customization | 🔴 | ⚪ |
|
||||
| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ |
|
||||
| <details><summary>Other accessibility configs (click to expand)</summary><ul><li>Press speed</li><li>Press and Hold duration</li><li>Noise Cancellation with single AirPod</li><li>Volume control on swipe</li><li>Volume swipe speed</li></ul></details> | 🔴 | ✅ |
|
||||
| <details><summary>Other general configs</summary><ul><li>Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)</li><li>Configure call controls</li><li>Personalized volume</li><li>Loud Sound Reduction (needs <a href="#vendorid-spoofing">VendorID spoofing</a>)</li><li>Microphone side</li><li>Pause media when falling asleep (needs a recent firmware)</li><li>Enable <code>Off listening mode</code> to switch to <code>Off</code></li></ul></details> | 🔴 | ✅ |
|
||||
| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ |
|
||||
| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 |
|
||||
| [Find My](#find-my) | ❓ | ❓ |
|
||||
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
|
||||
|
||||
| Symbol | Meaning |
|
||||
| ------ | ------------------------------------------------------------------- |
|
||||
| ✅ | Implemented and works well |
|
||||
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
|
||||
| 🔴 | Not implemented yet; planned |
|
||||
| ⛔ | Will not be implemented |
|
||||
| ❓ | Unknown |
|
||||
|
||||
## Find My
|
||||
|
||||
The following features related to Find My are planned, but require further RE and might need root on Android:
|
||||
|
||||
- Add your AirPods to the Find My network
|
||||
- Play sound through charging case to find it
|
||||
- Notify when leaving behind
|
||||
- Toggle case charging sounds
|
||||
|
||||
## Spatial Audio
|
||||
|
||||
The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root.
|
||||
|
||||
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
|
||||
|
||||
## Heart Rate Monitoring (AirPods Pro 3 and later)
|
||||
This is being worked upon, check the #reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android.
|
||||
|
||||
## High quality two-way audio
|
||||
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
|
||||
|
||||
Since this needs deeper integration with audio on Android, it will most likely need root.
|
||||
|
||||
# Installation
|
||||
|
||||
- [**Android**](/android/README.md)
|
||||
- [**Linux**](/linux/README.md)
|
||||
|
||||
# VendorID Spoofing
|
||||
|
||||
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
|
||||
|
||||
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
|
||||
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled).
|
||||
|
||||
### Multi-device Connectivity
|
||||
## Multi-device Connectivity
|
||||
|
||||
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
|
||||
|
||||
### Accessibility Settings and Hearing Aid
|
||||
## Accessibility Settings and Hearing Aid
|
||||
|
||||
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
|
||||
|
||||
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
|
||||
|
||||
#### A few notes
|
||||
# Protocol and Reverse Engineering
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
||||
Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark)
|
||||
|
||||
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
|
||||
The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts!
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
# Use of AI
|
||||
|
||||
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
|
||||
## Android app
|
||||
|
||||
## Supporters
|
||||
These parts of the app were completely AI-generated:
|
||||
- Head Gestures - all of it, including logic and the UI
|
||||
- The offset setup with r2+the xposed module (both versions)
|
||||
- Troubleshooter and LogCollector
|
||||
|
||||
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
|
||||
|
||||
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
## Linux (rewrite)
|
||||
|
||||
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
|
||||
|
||||
# Supporters
|
||||
|
||||
A huge thank you to everyone supporting the project!
|
||||
- @davdroman
|
||||
- @tedsalmon
|
||||
- @wiless
|
||||
- @SmartMsg
|
||||
- @lunaroyster
|
||||
- @ressiwage
|
||||
|
||||
## Special thanks
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/davdroman">
|
||||
<img src="https://github.com/davdroman.png?size=48" width="48" height="48"alt="davdroman"/><br />
|
||||
@davdroman
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/tedsalmon">
|
||||
<img src="https://github.com/tedsalmon.png?size=48" width="48" height="48"alt="tedsalmon"/><br />
|
||||
@tedsalmon
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/wiless">
|
||||
<img src="https://github.com/wiless.png?size=48" width="48" height="48"alt="wiless"/><br />
|
||||
@wiless
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/SmartMsg">
|
||||
<img src="https://github.com/SmartMsg.png?size=48" width="48" height="48"alt="SmartMsg"/><br />
|
||||
@SmartMsg
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lunaroyster">
|
||||
<img src="https://github.com/lunaroyster.png?size=48" width="48" height="48"alt="lunaroyster"/><br />
|
||||
@lunaroyster
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ressiwage">
|
||||
<img src="https://github.com/ressiwage.png?size=48" width="48" height="48"alt="ressiwage"/><br />
|
||||
@ressiwage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/kkjdroid">
|
||||
<img src="https://github.com/kkjdroid.png?size=48" width="48" height="48"alt="kkjdroid"/><br />
|
||||
@kkjdroid
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/CitrusJoules">
|
||||
<img src="https://github.com/CitrusJoules.png?size=48" width="48" height="48"alt="CitrusJoules"/><br />
|
||||
@CitrusJoules
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/DanielReyesDev">
|
||||
<img src="https://github.com/DanielReyesDev.png?size=48" width="48" height="48"alt="DanielReyesDev"/><br />
|
||||
@DanielReyesDev
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/sumitduster">
|
||||
<img src="https://github.com/sumitduster.png?size=48" width="48" height="48"alt="sumitduster"/><br />
|
||||
@sumitduster
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/GrifTheDev">
|
||||
<img src="https://github.com/GrifTheDev.png?size=48" width="48" height="48"alt="GrifTheDev"/><br />
|
||||
@GrifTheDev
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
# Special Thanks
|
||||
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
|
||||
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
|
||||
- @devnoname120 for helping with the first root patch
|
||||
- @timgromeyer for making the first version of the linux app
|
||||
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
|
||||
- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest!
|
||||
|
||||
## Star History
|
||||
# Alternates for other platforms:
|
||||
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
|
||||
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
|
||||
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
|
||||
|
||||
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
||||
# Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=librepods-org%2Flibrepods&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&theme=dark&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -144,4 +250,12 @@ 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/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
# Trademark Notice
|
||||
|
||||
The GPL does not grant any rights to use the LibrePods name, logo, or branding. The LibrePods name and logo may not be used for software, websites, domains, products, services, or other projects in a manner that suggests affiliation with, endorsement by, or association with the official LibrePods project without prior permission.
|
||||
|
||||
If you see any misuse of the LibrePods name or logo, please report it to [me@kavish.xyz](mailto:me@kavish.xyz).
|
||||
|
||||
The SF Pro font used in the Android app is the property of Apple Inc.. This will be removed in future versions of the app and replaced with an open alternative soon.
|
||||
|
||||
AirPods, AirPods Pro, AirPods Max, and the AirPods logo are trademarks of Apple Inc. The LibrePods project is not affiliated with or endorsed by Apple Inc. in any way.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
## Root Requirement
|
||||
|
||||
LibrePods *may* require root depending on your device/OS and what features you want access to:
|
||||
|
||||
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
|
||||
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
|
||||
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This workaround with Xposed is not guaranteed to work on all devices.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### Google Play Store
|
||||
|
||||
If you are using a supported device/OS combination, you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
|
||||
|
||||
### As a system app (root module)
|
||||
|
||||
If you want LibrePods to have privileged Bluetooth permissions to
|
||||
- show battery status in the system settings and widgets
|
||||
- show AirPods icon in the system settings (xposed is also currently required for this)
|
||||
- switch audio to phone speakers when you are not wearing your AirPods
|
||||
|
||||
you can install the root module. This is optional and only provides extra features, but it is not required for the app to work.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module.
|
||||
|
||||
## Nightly/Development Builds
|
||||
|
||||
Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly).
|
||||
|
||||
> [!WARNING]
|
||||
> These builds are automatically generated from the latest code and may contain new features and bug fixes that haven't been included in a stable release yet. However, please note that they may also be less stable than official releases, so use them at your own risk.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | | |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
|
||||
here's a very unprofessional demo video
|
||||
|
||||
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
### Troubleshooting steps for common errors
|
||||
- Ensure the correct scope is set in LSPosed/Vector.
|
||||
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
|
||||
- Restart your phone after confirming the scope.
|
||||
|
||||
### A few notes
|
||||
|
||||
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
|
||||
|
||||
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
|
||||
+215
-26
@@ -1,42 +1,134 @@
|
||||
import java.util.Properties
|
||||
|
||||
val appVersionName = "1.0.0-rc2"
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.room3)
|
||||
alias(libs.plugins.protobuf)
|
||||
// alias(libs.plugins.hilt)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
val localPropsFile = rootProject.file("local.properties")
|
||||
val props = Properties().apply {
|
||||
if (localPropsFile.exists()) {
|
||||
load(localPropsFile.inputStream())
|
||||
}
|
||||
}
|
||||
|
||||
val releaseSigningAvailable = listOf(
|
||||
"RELEASE_STORE_FILE",
|
||||
"RELEASE_STORE_PASSWORD",
|
||||
"RELEASE_KEY_ALIAS",
|
||||
"RELEASE_KEY_PASSWORD"
|
||||
).all { props[it]?.toString()?.isNotBlank() == true }
|
||||
|
||||
room3 {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = libs.protobuf.protoc.get().toString()
|
||||
}
|
||||
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java")
|
||||
create("kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
optIn.addAll(
|
||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||
"kotlin.uuid.ExperimentalUuidApi",
|
||||
"kotlinx.coroutines.FlowPreview",
|
||||
"kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
signingConfigs {
|
||||
if (releaseSigningAvailable) {
|
||||
create("release") {
|
||||
storeFile = file(props["RELEASE_STORE_FILE"] as String)
|
||||
storePassword = props["RELEASE_STORE_PASSWORD"] as String
|
||||
keyAlias = props["RELEASE_KEY_ALIAS"] as String
|
||||
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace = "me.kavishdevar.librepods"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 9
|
||||
versionName = "0.2.0"
|
||||
targetSdk = 37
|
||||
versionCode = 85
|
||||
versionName = appVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||
}
|
||||
}
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
debug {
|
||||
if (releaseSigningAvailable) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
versionNameSuffix = "-debug"
|
||||
defaultConfig {
|
||||
minSdk = 33
|
||||
}
|
||||
}
|
||||
}
|
||||
productFlavors {
|
||||
create("foss") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
}
|
||||
create("play") {
|
||||
dimension = "env"
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-play"
|
||||
minSdk = 36
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
@@ -49,18 +141,23 @@ android {
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
||||
res.directories += "src/main/res-apple"
|
||||
}
|
||||
}
|
||||
|
||||
ndkVersion = "30.0.14904198"
|
||||
|
||||
flavorDimensions += "env"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.hiddenapibypass)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.foundation.layout)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
@@ -71,22 +168,114 @@ dependencies {
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.billing)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.aboutlibraries)
|
||||
implementation(libs.aboutlibraries.compose)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
||||
implementation(libs.backdrop)
|
||||
// implementation(libs.hilt)
|
||||
// implementation(libs.hilt.compiler)
|
||||
compileOnly(libs.libxposed.api)
|
||||
implementation(libs.libxposed.service)
|
||||
implementation(libs.play.review)
|
||||
implementation(libs.play.review.ktx)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.navigationevent)
|
||||
implementation(libs.androidx.room3.runtime)
|
||||
ksp(libs.androidx.room3.compiler)
|
||||
implementation(libs.kotlinx.serialization.cbor)
|
||||
implementation(libs.protobuf.kotlin)
|
||||
// compileOnly(files("../../../framework-classes.jar"))
|
||||
implementation(libs.androidx.healthconnect.client)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
export{
|
||||
export {
|
||||
prettyPrint = true
|
||||
excludeFields = listOf("generated")
|
||||
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
||||
}
|
||||
}
|
||||
|
||||
val rootModuleDir = rootProject.file("../root-module-manual")
|
||||
val releaseDir = rootProject.file("../release")
|
||||
|
||||
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
|
||||
|
||||
fun registerRootModuleZipTask(
|
||||
name: String,
|
||||
flavor: String,
|
||||
buildType: String
|
||||
) = tasks.register<Zip>(name) {
|
||||
description = "Zips the root module for the $flavor-$buildType variant."
|
||||
|
||||
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") {
|
||||
|
||||
description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||
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") {
|
||||
description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||
dependsOn(collect)
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+3
-1
@@ -18,4 +18,6 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c547604d916583c3b94c7bf1ac787fdf",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c547604d916583c3b94c7bf1ac787fdf')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "721149fa5e2a13e82b73a83de312968a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "721149fa5e2a13e82b73a83de312968a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `overrideMaterialColor` INTEGER, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "overrideMaterialColor",
|
||||
"columnName": "overrideMaterialColor",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -17,24 +17,42 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_PHONE_STATE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.LOCAL_MAC_ADDRESS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_AUDIO_ROUTING"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
|
||||
|
||||
<application
|
||||
android:name=".LibrePodsApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
@@ -45,10 +63,9 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LibrePods"
|
||||
android:description="@string/app_description"
|
||||
tools:ignore="UnusedAttribute"
|
||||
tools:targetApi="31">
|
||||
tools:ignore="UnusedAttribute" >
|
||||
<receiver
|
||||
android:name=".widgets.NoiseControlWidget"
|
||||
android:name=".presentation.widgets.NoiseControlWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -60,7 +77,7 @@
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widgets.BatteryWidget"
|
||||
android:name=".presentation.widgets.BatteryWidget"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
@@ -72,7 +89,7 @@
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".presentation.activities.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
@@ -90,16 +107,11 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".QuickSettingsDialogActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TransparentDialog"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
/>
|
||||
android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".services.AirPodsService"
|
||||
android:name=".services.LibrePodsService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
@@ -107,24 +119,24 @@
|
||||
<service
|
||||
android:name=".services.AirPodsQSService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/airpods"
|
||||
android:icon="@drawable/ic_airpods"
|
||||
android:label="ANC Mode"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".services.AppListenerService"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/app_listener_service_config" />
|
||||
</service>
|
||||
<!-- <service-->
|
||||
<!-- android:name=".services.AppListenerService"-->
|
||||
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
|
||||
<!-- android:exported="true">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- <meta-data-->
|
||||
<!-- android:name="android.accessibilityservice"-->
|
||||
<!-- android:resource="@xml/app_listener_service_config" />-->
|
||||
<!-- </service>-->
|
||||
<receiver
|
||||
android:name=".receivers.BootReceiver"
|
||||
android:enabled="true"
|
||||
@@ -145,6 +157,28 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.activities.PrivacyPolicyActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
</application>
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -3,8 +3,30 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||
project("l2c_fcr_hook")
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
|
||||
add_library(l2c_fcr_hook SHARED
|
||||
l2c_fcr_hook.cpp
|
||||
add_library(hiddenapi SHARED
|
||||
hiddenapi.cpp
|
||||
)
|
||||
|
||||
target_compile_options(hiddenapi PRIVATE
|
||||
-O2
|
||||
-fvisibility=hidden
|
||||
)
|
||||
|
||||
target_link_options(hiddenapi PRIVATE
|
||||
-Wl,--strip-all
|
||||
-Wl,--gc-sections
|
||||
)
|
||||
|
||||
target_link_libraries(hiddenapi
|
||||
android
|
||||
log
|
||||
)
|
||||
|
||||
|
||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||
|
||||
add_library(fluoride_hooks SHARED
|
||||
fluoride_hooks.cpp
|
||||
|
||||
xz/xz_crc32.c
|
||||
xz/xz_crc64.c
|
||||
@@ -14,11 +36,11 @@ add_library(l2c_fcr_hook SHARED
|
||||
xz/xz_dec_bcj.c
|
||||
)
|
||||
|
||||
target_include_directories(l2c_fcr_hook PRIVATE
|
||||
target_include_directories(fluoride_hooks PRIVATE
|
||||
xz
|
||||
)
|
||||
|
||||
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
target_compile_definitions(fluoride_hooks PRIVATE
|
||||
XZ_DEC_X86
|
||||
XZ_DEC_ARM
|
||||
XZ_DEC_ARMTHUMB
|
||||
@@ -29,6 +51,7 @@ target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
XZ_DEC_CONCATENATED
|
||||
)
|
||||
|
||||
target_link_libraries(l2c_fcr_hook
|
||||
target_link_libraries(fluoride_hooks
|
||||
android
|
||||
log)
|
||||
log
|
||||
)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
#include <atomic>
|
||||
#include <jni.h>
|
||||
#include "helpers.h"
|
||||
|
||||
#include "fluoride_hooks.h"
|
||||
|
||||
static std::atomic<bool> enableSdpHook(false);
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes called");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord called");
|
||||
|
||||
if (original_BTA_DmSetLocalDiRecord &&
|
||||
enableSdpHook.load(std::memory_order_relaxed))
|
||||
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
|
||||
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
|
||||
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
|
||||
: BTA_FAILURE);
|
||||
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
|
||||
p_handle)
|
||||
: BTA_FAILURE;
|
||||
}
|
||||
|
||||
|
||||
static bool hookLibrary(const char *libname) {
|
||||
LOGI("hookLibrary called with libname: %s", libname);
|
||||
|
||||
if (!hook_func) {
|
||||
LOGE("hook_func not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string path;
|
||||
if (!getLibraryPath(libname, path)) {
|
||||
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;
|
||||
}
|
||||
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
LOGE("hookLibrary: fstat failed");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
|
||||
|
||||
std::vector<uint8_t> file(st.st_size);
|
||||
read(fd, file.data(), st.st_size);
|
||||
close(fd);
|
||||
|
||||
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
|
||||
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
if (chk_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + chk_offset);
|
||||
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
|
||||
(void **) &original_l2c_fcr_chk_chan_modes);
|
||||
LOGI("hooked chk");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + sdp_offset);
|
||||
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
|
||||
(void **) &original_BTA_DmSetLocalDiRecord);
|
||||
LOGI("hooked sdp");
|
||||
}
|
||||
|
||||
return chk_offset || sdp_offset;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char *name, void *) {
|
||||
LOGI("on_library_loaded called with name: %s", name);
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Bluetooth JNI loaded");
|
||||
hookLibrary("libbluetooth_jni.so");
|
||||
}
|
||||
|
||||
if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Bluetooth QTI loaded");
|
||||
hookLibrary("libbluetooth_qti.so");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
|
||||
LOGI("native_init called with entries: %p", entries);
|
||||
hook_func = (HookFunType) entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
|
||||
enableSdpHook.load(std::memory_order_relaxed));
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
#include <jni.h>
|
||||
|
||||
|
||||
extern "C" {
|
||||
#include "xz.h"
|
||||
}
|
||||
|
||||
#define LOG_TAG "LibrePodsHook"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
|
||||
|
||||
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");
|
||||
|
||||
struct xz_buf buf{};
|
||||
buf.in = input;
|
||||
buf.in_pos = 0;
|
||||
buf.in_size = input_size;
|
||||
|
||||
output.resize(input_size * 8);
|
||||
|
||||
buf.out = output.data();
|
||||
buf.out_pos = 0;
|
||||
buf.out_size = output.size();
|
||||
|
||||
LOGI("decompressXZ: entering decompression loop");
|
||||
while (true) {
|
||||
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos,
|
||||
buf.out_pos);
|
||||
enum xz_ret ret = xz_dec_run(dec, &buf);
|
||||
|
||||
LOGI("decompressXZ: xz_dec_run returned %d", ret);
|
||||
|
||||
if (ret == XZ_STREAM_END)
|
||||
break;
|
||||
|
||||
if (ret != XZ_OK) {
|
||||
LOGE("decompressXZ: xz_dec_run error");
|
||||
xz_dec_end(dec);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buf.out_pos == buf.out_size) {
|
||||
size_t old = output.size();
|
||||
LOGI("decompressXZ: resizing output to %zu", old * 2);
|
||||
output.resize(old * 2);
|
||||
buf.out = output.data();
|
||||
buf.out_size = output.size();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
LOGI("getLibraryPath: failed to find path for %s", name);
|
||||
return false;
|
||||
}
|
||||
|
||||
static uintptr_t getModuleBase(const char *name) {
|
||||
LOGI("getModuleBase called with name: %s", name);
|
||||
|
||||
FILE *fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("getModuleBase: fopen failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
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%x", 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;
|
||||
}
|
||||
@@ -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("L");
|
||||
|
||||
JNIEnv* env;
|
||||
getVm()->AttachCurrentThread(&env, nullptr);
|
||||
|
||||
jclass vmRuntime = env->FindClass(DEC(c1));
|
||||
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
|
||||
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
|
||||
|
||||
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
|
||||
jobjectArray prefixes = env->NewObjectArray(
|
||||
2, env->FindClass(DEC(c6)), nullptr);
|
||||
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
|
||||
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
|
||||
|
||||
env->CallVoidMethod(runtime, setExemptions, prefixes);
|
||||
getVm()->DetachCurrentThread();
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
pthread_t t;
|
||||
pthread_create(&t, nullptr, fn, nullptr);
|
||||
pthread_join(t, nullptr);
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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/>.
|
||||
*/
|
||||
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
|
||||
#include "l2c_fcr_hook.h"
|
||||
|
||||
extern "C" {
|
||||
#include "xz.h"
|
||||
}
|
||||
|
||||
#define LOG_TAG "LibrePods"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
|
||||
tSDP_DI_RECORD*, uint32_t*) = nullptr;
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
LOGI("l2c_fcr_chk_chan_modes hooked");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||
|
||||
LOGI("Original returned %d, forcing 1", orig);
|
||||
return 1;
|
||||
}
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
|
||||
tSDP_DI_RECORD* p_device_info,
|
||||
uint32_t* p_handle) {
|
||||
|
||||
LOGI("BTA_DmSetLocalDiRecord hooked");
|
||||
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
|
||||
if (original_BTA_DmSetLocalDiRecord)
|
||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
return BTA_FAILURE;
|
||||
}
|
||||
|
||||
static bool decompressXZ(
|
||||
const uint8_t* input,
|
||||
size_t input_size,
|
||||
std::vector<uint8_t>& output) {
|
||||
|
||||
xz_crc32_init();
|
||||
#ifdef XZ_USE_CRC64
|
||||
xz_crc64_init();
|
||||
#endif
|
||||
|
||||
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||
if (!dec) return false;
|
||||
|
||||
struct xz_buf buf{};
|
||||
buf.in = input;
|
||||
buf.in_pos = 0;
|
||||
buf.in_size = input_size;
|
||||
|
||||
output.resize(input_size * 8);
|
||||
|
||||
buf.out = output.data();
|
||||
buf.out_pos = 0;
|
||||
buf.out_size = output.size();
|
||||
|
||||
while (true) {
|
||||
enum xz_ret ret = xz_dec_run(dec, &buf);
|
||||
|
||||
if (ret == XZ_STREAM_END)
|
||||
break;
|
||||
|
||||
if (ret != XZ_OK) {
|
||||
xz_dec_end(dec);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buf.out_pos == buf.out_size) {
|
||||
size_t old = output.size();
|
||||
output.resize(old * 2);
|
||||
buf.out = output.data();
|
||||
buf.out_size = output.size();
|
||||
}
|
||||
}
|
||||
|
||||
output.resize(buf.out_pos);
|
||||
xz_dec_end(dec);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool getLibraryPath(const char* name, std::string& out) {
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) return false;
|
||||
|
||||
char line[1024];
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
char* path = strchr(line, '/');
|
||||
if (path) {
|
||||
out = path;
|
||||
out.erase(out.find('\n'));
|
||||
fclose(fp);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return false;
|
||||
}
|
||||
|
||||
static uintptr_t getModuleBase(const char* name) {
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) return 0;
|
||||
|
||||
char line[1024];
|
||||
uintptr_t base = 0;
|
||||
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
base = strtoull(line, nullptr, 16);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose(fp);
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t findSymbolOffset(
|
||||
const std::vector<uint8_t>& elf,
|
||||
const char* 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;
|
||||
|
||||
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)
|
||||
return 0;
|
||||
|
||||
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);
|
||||
|
||||
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("Resolved %s at 0x%lx",
|
||||
name,
|
||||
(unsigned long)symbols[i].st_value);
|
||||
|
||||
return symbols[i].st_value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool hookLibrary(const char* libname) {
|
||||
|
||||
if (!hook_func) {
|
||||
LOGE("hook_func not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string path;
|
||||
if (!getLibraryPath(libname, path)) {
|
||||
LOGE("Failed to locate %s", libname);
|
||||
return false;
|
||||
}
|
||||
|
||||
int fd = open(path.c_str(), O_RDONLY);
|
||||
if (fd < 0) return false;
|
||||
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
|
||||
std::vector<uint8_t> compressed(
|
||||
file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
|
||||
std::vector<uint8_t> decompressed;
|
||||
|
||||
if (!decompressXZ(
|
||||
compressed.data(),
|
||||
compressed.size(),
|
||||
decompressed))
|
||||
return false;
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) return false;
|
||||
|
||||
uint64_t chk_offset =
|
||||
findSymbolOffset(decompressed,
|
||||
"l2c_fcr_chk_chan_modes");
|
||||
|
||||
uint64_t sdp_offset =
|
||||
findSymbolOffset(decompressed,
|
||||
"BTA_DmSetLocalDiRecord");
|
||||
|
||||
if (chk_offset) {
|
||||
void* target =
|
||||
reinterpret_cast<void*>(base + chk_offset);
|
||||
|
||||
hook_func(target,
|
||||
(void*)fake_l2c_fcr_chk_chan_modes,
|
||||
(void**)&original_l2c_fcr_chk_chan_modes);
|
||||
|
||||
LOGI("Hooked l2c_fcr_chk_chan_modes");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void* target =
|
||||
reinterpret_cast<void*>(base + sdp_offset);
|
||||
|
||||
hook_func(target,
|
||||
(void*)fake_BTA_DmSetLocalDiRecord,
|
||||
(void**)&original_BTA_DmSetLocalDiRecord);
|
||||
|
||||
LOGI("Hooked BTA_DmSetLocalDiRecord");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char* name, void*) {
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Bluetooth JNI loaded");
|
||||
hookLibrary("libbluetooth_jni.so");
|
||||
}
|
||||
|
||||
if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Bluetooth QTI loaded");
|
||||
hookLibrary("libbluetooth_qti.so");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
[[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
|
||||
LOGI("LibrePods initialized");
|
||||
|
||||
hook_func = (HookFunType)entries->hook_func;
|
||||
|
||||
return on_library_loaded;
|
||||
}
|
||||
@@ -1,804 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
||||
import me.kavishdevar.librepods.screens.DebugScreen
|
||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
||||
import me.kavishdevar.librepods.screens.LongPress
|
||||
// import me.kavishdevar.librepods.screens.Onboarding
|
||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
||||
import me.kavishdevar.librepods.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
||||
import me.kavishdevar.librepods.screens.VersionScreen
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
Main()
|
||||
}
|
||||
}
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent) {
|
||||
val data: Uri? = intent.data
|
||||
|
||||
if (data != null && data.scheme == "librepods") {
|
||||
when (data.host) {
|
||||
"add-magic-keys" -> {
|
||||
val queryParams = data.queryParameterNames
|
||||
queryParams.forEach { param ->
|
||||
val value = data.getQueryParameter(param)
|
||||
Log.d("LibrePods", "Parameter: $param = $value")
|
||||
}
|
||||
|
||||
handleAddMagicKeys(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddMagicKeys(uri: Uri) {
|
||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
val irkHex = uri.getQueryParameter("irk")
|
||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
||||
|
||||
try {
|
||||
if (irkHex != null && validateHexInput(irkHex)) {
|
||||
val irkBytes = hexStringToByteArray(irkHex)
|
||||
val irkBase64 = Base64.encode(irkBytes)
|
||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
||||
}
|
||||
|
||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
||||
val result = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
||||
result[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val isConnected = remember { mutableStateOf(false) }
|
||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
||||
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
||||
val context = LocalContext.current
|
||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
||||
|
||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
"android.permission.BLUETOOTH_SCAN",
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.BLUETOOTH_ADVERTISE"
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH",
|
||||
"android.permission.BLUETOOTH_ADMIN",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
)
|
||||
}
|
||||
val otherPermissions = listOf(
|
||||
"android.permission.POST_NOTIFICATIONS",
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
val allPermissions = bluetoothPermissions + otherPermissions
|
||||
|
||||
val permissionState = rememberMultiplePermissionsState(
|
||||
permissions = allPermissions
|
||||
)
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||
}
|
||||
|
||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
){
|
||||
val backButtonBackdrop = rememberLayerBackdrop()
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||
.layerBackdrop(backButtonBackdrop)
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
|
||||
enterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
exitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it/4 },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
||||
}
|
||||
) {
|
||||
composable("settings") {
|
||||
if (airPodsService.value != null) {
|
||||
AirPodsSettingsScreen(
|
||||
dev = airPodsService.value?.device,
|
||||
service = airPodsService.value!!,
|
||||
navController = navController,
|
||||
isConnected = isConnected.value,
|
||||
isRemotelyConnected = isRemotelyConnected.value
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("debug") {
|
||||
DebugScreen(navController = navController)
|
||||
}
|
||||
composable("long_press/{bud}") { navBackStackEntry ->
|
||||
LongPress(
|
||||
navController = navController,
|
||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
||||
)
|
||||
}
|
||||
composable("rename") {
|
||||
RenameScreen(navController)
|
||||
}
|
||||
composable("app_settings") {
|
||||
AppSettingsScreen(navController)
|
||||
}
|
||||
composable("troubleshooting") {
|
||||
TroubleshootingScreen(navController)
|
||||
}
|
||||
composable("head_tracking") {
|
||||
HeadTrackingScreen()
|
||||
}
|
||||
/*composable("onboarding") {
|
||||
Onboarding(navController, context)
|
||||
}*/
|
||||
composable("accessibility") {
|
||||
AccessibilitySettingsScreen(navController)
|
||||
}
|
||||
composable("transparency_customization") {
|
||||
TransparencySettingsScreen(navController)
|
||||
}
|
||||
composable("hearing_aid") {
|
||||
HearingAidScreen(navController)
|
||||
}
|
||||
composable("hearing_aid_adjustments") {
|
||||
HearingAidAdjustmentsScreen(navController)
|
||||
}
|
||||
composable("adaptive_strength") {
|
||||
AdaptiveStrengthScreen(navController)
|
||||
}
|
||||
composable("camera_control") {
|
||||
CameraControlScreen(navController)
|
||||
}
|
||||
composable("open_source_licenses") {
|
||||
OpenSourceLicensesScreen(navController)
|
||||
}
|
||||
composable("update_hearing_test") {
|
||||
UpdateHearingTestScreen(navController)
|
||||
}
|
||||
composable("version_info") {
|
||||
VersionScreen(navController)
|
||||
}
|
||||
composable("hearing_protection") {
|
||||
HearingProtectionScreen(navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showBackButton = remember{ mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(navController) {
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
showBackButton.value = destination.route != "settings" // && destination.route != "onboarding"
|
||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBackButton.value,
|
||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||
)
|
||||
) {
|
||||
StyledIconButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
icon = "",
|
||||
darkMode = isSystemInDarkTheme(),
|
||||
backdrop = backButtonBackdrop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceConnection = remember {
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService.value = binder.getService()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
|
||||
if (airPodsService.value?.isConnectedLocally == true) {
|
||||
isConnected.value = true
|
||||
}
|
||||
} else {
|
||||
PermissionsScreen(
|
||||
permissionState = permissionState,
|
||||
canDrawOverlays = canDrawOverlays,
|
||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
permissionState: MultiplePermissionsState,
|
||||
canDrawOverlays: Boolean,
|
||||
onOverlaySettingsReturn: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulse scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uDBC2\uDEB7",
|
||||
style = TextStyle(
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.scale(pulseScale)
|
||||
) {
|
||||
val radius = size.minDimension / 2.2f
|
||||
val centerX = size.width / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
rotate(degrees = 45f) {
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.1f),
|
||||
radius = radius * 1.3f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
|
||||
drawCircle(
|
||||
color = accentColor.copy(alpha = 0.2f),
|
||||
radius = radius * 1.1f,
|
||||
center = Offset(centerX, centerY)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Permission Required",
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_required),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
PermissionCard(
|
||||
title = "Bluetooth Permissions",
|
||||
description = "Required to communicate with your AirPods",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("BLUETOOTH")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Notification Permission",
|
||||
description = "To show battery status",
|
||||
icon = Icons.Default.Notifications,
|
||||
isGranted = permissionState.permissions.find {
|
||||
it.permission == "android.permission.POST_NOTIFICATIONS"
|
||||
}?.status?.isGranted == true,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Phone Permissions",
|
||||
description = "For answering calls with Head Gestures",
|
||||
icon = Icons.Default.Phone,
|
||||
isGranted = permissionState.permissions.filter {
|
||||
it.permission.contains("PHONE") || it.permission.contains("CALLS")
|
||||
}.all { it.status.isGranted },
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
PermissionCard(
|
||||
title = "Display Over Other Apps",
|
||||
description = "For popup animations when AirPods connect",
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
|
||||
isGranted = canDrawOverlays,
|
||||
backgroundColor = backgroundColor,
|
||||
textColor = textColor,
|
||||
accentColor = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { permissionState.launchMultiplePermissionRequest() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = accentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Ask for regular permissions",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
"package:${context.packageName}".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
onOverlaySettingsReturn()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (canDrawOverlays) Color.Gray else accentColor
|
||||
),
|
||||
enabled = !canDrawOverlays,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!canDrawOverlays && basicPermissionsGranted) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
context.getSharedPreferences("settings", MODE_PRIVATE).edit {
|
||||
putBoolean("overlay_permission_skipped", true)
|
||||
}
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(55.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF757575)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"Continue without overlay",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color.White
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ImageVector,
|
||||
isGranted: Boolean,
|
||||
backgroundColor: Color,
|
||||
textColor: Color,
|
||||
accentColor: Color
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||
alpha = 0.15f
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
tint = if (isGranted) accentColor else Color.Gray,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isGranted) "✓" else "!",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||
|
||||
private var airPodsService: AirPodsService? = null
|
||||
private var isBound = false
|
||||
|
||||
private var isNoiseControlExpandedState by mutableStateOf(false)
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService = binder.getService()
|
||||
isBound = true
|
||||
Log.d("QSActivity", "Service bound")
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
isBound = false
|
||||
airPodsService = null
|
||||
Log.d("QSActivity", "Service unbound")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
||||
window.setGravity(Gravity.BOTTOM)
|
||||
|
||||
Intent(this, AirPodsService::class.java).also { intent ->
|
||||
bindService(intent, connection, BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isBound) {
|
||||
unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableDismissBox(
|
||||
onDismiss: () -> Unit,
|
||||
onlyCollapseWhenClicked: () -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = 400f
|
||||
|
||||
val animatedOffset = remember { Animatable(0f) }
|
||||
val animatedScale = remember { Animatable(1f) }
|
||||
val animatedAlpha = remember { Animatable(1f) }
|
||||
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
targetValue = if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
|
||||
1f - dragProgress
|
||||
} else 1f,
|
||||
label = "BackgroundFade"
|
||||
)
|
||||
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) {
|
||||
if (abs(dragOffset) < dismissThreshold) {
|
||||
val springSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessHigh,
|
||||
visibilityThreshold = 0.1f
|
||||
)
|
||||
launch { animatedOffset.animateTo(0f, springSpec) }
|
||||
launch { animatedScale.animateTo(1f, springSpec) }
|
||||
launch { animatedAlpha.animateTo(1f, tween(100)) }
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragOffset, isDragging) {
|
||||
if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||
|
||||
animatedOffset.snapTo(dragOffset)
|
||||
animatedScale.snapTo(1f - dragProgress * 0.3f)
|
||||
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { isDragging = true },
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (abs(dragOffset) > dismissThreshold) {
|
||||
coroutineScope.launch {
|
||||
val direction = if (dragOffset > 0) 1f else -1f
|
||||
|
||||
launch {
|
||||
animatedOffset.animateTo(
|
||||
direction * 1500f,
|
||||
tween(350, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
launch { animatedScale.animateTo(0.7f, tween(350)) }
|
||||
launch { animatedAlpha.animateTo(0f, tween(250)) }
|
||||
|
||||
kotlinx.coroutines.delay(350)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = { isDragging = false },
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onlyCollapseWhenClicked()
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
translationY = animatedOffset.value,
|
||||
scaleX = animatedScale.value,
|
||||
scaleY = animatedScale.value,
|
||||
alpha = animatedAlpha.value
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun NewControlCenterDialogContent(
|
||||
service: AirPodsService?,
|
||||
isNoiseControlExpanded: Boolean,
|
||||
onNoiseControlExpandedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val textColor = Color.White
|
||||
|
||||
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
|
||||
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
|
||||
val availableModes = remember(isOffModeEnabled) {
|
||||
mutableListOf(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
NoiseControlMode.NOISE_CANCELLATION
|
||||
).apply {
|
||||
if (isOffModeEnabled) {
|
||||
add(0, NoiseControlMode.OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
|
||||
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
|
||||
val animatedVolumeFraction by animateFloatAsState(
|
||||
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "VolumeAnimation"
|
||||
)
|
||||
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
|
||||
var isDraggingVolume by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
|
||||
if (!isDraggingVolume) {
|
||||
liveDragFraction = animatedVolumeFraction
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(service, availableModes) {
|
||||
val ancReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
|
||||
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
|
||||
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (availableModes.contains(newMode)) {
|
||||
currentAncMode = newMode
|
||||
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(ancReceiver, filter)
|
||||
}
|
||||
|
||||
service?.let {
|
||||
val initialModeOrdinal = it.getANC().minus(1)
|
||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (!availableModes.contains(initialMode)) {
|
||||
initialMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
currentAncMode = initialMode
|
||||
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
|
||||
}
|
||||
|
||||
onDispose {
|
||||
context.unregisterReceiver(ancReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val volumeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
|
||||
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (newVolume != currentVolumeInt) {
|
||||
currentVolumeInt = newVolume
|
||||
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
|
||||
context.registerReceiver(volumeReceiver, filter)
|
||||
onDispose {
|
||||
context.unregisterReceiver(volumeReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 24.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent()
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (service != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Device Icon",
|
||||
tint = textColor.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = deviceName,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
VerticalVolumeSlider(
|
||||
displayFraction = animatedVolumeFraction,
|
||||
maxVolume = maxVolume,
|
||||
onVolumeChange = { newVolume ->
|
||||
currentVolumeInt = newVolume
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
|
||||
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
|
||||
},
|
||||
initialFraction = animatedVolumeFraction,
|
||||
onDragStateChange = { dragging -> isDraggingVolume = dragging },
|
||||
baseSliderHeight = 400.dp,
|
||||
baseSliderWidth = 145.dp,
|
||||
baseCornerRadius = 48.dp,
|
||||
maxStretchFactor = 1.15f,
|
||||
minCompressionFactor = 0.875f,
|
||||
stretchSensitivity = 0.3f,
|
||||
compressionSensitivity = 0.3f,
|
||||
cornerRadiusChangeFactor = -0.5f,
|
||||
directionalStretchRatio = 0.75f,
|
||||
modifier = Modifier
|
||||
.width(145.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 72.dp)
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isNoiseControlExpanded,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "NoiseControlCrossfade"
|
||||
) { expanded ->
|
||||
if (expanded) {
|
||||
ControlCenterNoiseControlSegmentedButton(
|
||||
availableModes = availableModes,
|
||||
selectedMode = currentAncMode,
|
||||
onModeSelected = { newMode ->
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
value = newMode.ordinal + 1
|
||||
)
|
||||
currentAncMode = newMode
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
|
||||
AdaptiveRainbowBrush
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = noiseControlButtonBrush ?:
|
||||
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
|
||||
)
|
||||
.clickable(
|
||||
onClick = { onNoiseControlExpandedChange(true) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = getModeIconRes(currentAncMode)),
|
||||
contentDescription = getModeLabel(currentAncMode),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = getModeLabel(currentAncMode),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val newState = !isConvAwarenessEnabled
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
value = newState
|
||||
)
|
||||
isConvAwarenessEnabled = newState
|
||||
},
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Conversational Awareness",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Conversational\nAwareness",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Loading...", color = textColor)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AboutCard(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.about),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.model.displayName,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_name),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.actualModelNumber,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
val serialNumbers = listOf(
|
||||
airpodsInstance.serialNumber?: "",
|
||||
" ${airpodsInstance.leftSerialNumber}",
|
||||
" ${airpodsInstance.rightSerialNumber}"
|
||||
)
|
||||
val serialNumber = remember { mutableStateOf(0) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.serial_number),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = serialNumbers[serialNumber.value],
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "version_info",
|
||||
navController = navController,
|
||||
name = stringResource(R.string.version),
|
||||
currentState = airpodsInstance.version3,
|
||||
independent = false,
|
||||
height = rowHeight.value + 32.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AudioSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
val airpodsInstance = service.airpodsInstance
|
||||
if (airpodsInstance == null) return
|
||||
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
|
||||
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
|
||||
) {
|
||||
return
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.audio),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.personalized_volume),
|
||||
description = stringResource(R.string.personalized_volume_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
||||
NavigationButton(
|
||||
to = "adaptive_strength",
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
navController = navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AudioSettingsPreview() {
|
||||
AudioSettings(rememberNavController())
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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)
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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)
|
||||
}
|
||||
}
|
||||
-470
@@ -1,470 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun CallControlSettings(hazeState: HazeState) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.call_controls),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
||||
|
||||
val pressOnceText = stringResource(R.string.press_once)
|
||||
val pressTwiceText = stringResource(R.string.press_twice)
|
||||
|
||||
var flipped by remember {
|
||||
mutableStateOf(
|
||||
callControlEnabledValue.contentEquals(
|
||||
byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||
|
||||
var showSinglePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||
|
||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
||||
) {
|
||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
||||
flipped = newFlipped
|
||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
||||
Log.d(
|
||||
"CallControlSettings",
|
||||
"Control command received, flipped: $newFlipped"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(flipped) {
|
||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.answer_call),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.press_once),
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showSinglePressDropdown) {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = now
|
||||
} else {
|
||||
if (now - lastDismissTimeSingle > 250L) {
|
||||
touchOffsetSingle = offset
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetSingle = offset
|
||||
if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) {
|
||||
showSinglePressDropdown = true
|
||||
}
|
||||
lastDismissTimeSingle = now
|
||||
parentDragActiveSingle = true
|
||||
parentHoveredIndexSingle = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetSingle ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndexSingle = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
parentHoveredIndexSingle = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveSingle = false
|
||||
parentHoveredIndexSingle = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.mute_unmute),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionSingle = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = singlePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showSinglePressDropdown,
|
||||
onDismissRequest = {
|
||||
showSinglePressDropdown = false
|
||||
lastDismissTimeSingle = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = singlePressAction,
|
||||
touchOffset = touchOffsetSingle,
|
||||
boxPosition = boxPositionSingle,
|
||||
externalHoveredIndex = parentHoveredIndexSingle,
|
||||
externalDragActive = parentDragActiveSingle,
|
||||
onOptionSelected = { option ->
|
||||
singlePressAction = option
|
||||
doublePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showSinglePressDropdown = false
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x03
|
||||
) else byteArrayOf(0x00, 0x02)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDoublePressDropdown) {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = now
|
||||
} else {
|
||||
if (now - lastDismissTimeDouble > 250L) {
|
||||
touchOffsetDouble = offset
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffsetDouble = offset
|
||||
if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) {
|
||||
showDoublePressDropdown = true
|
||||
}
|
||||
lastDismissTimeDouble = now
|
||||
parentDragActiveDouble = true
|
||||
parentHoveredIndexDouble = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffsetDouble ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndexDouble = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble?.let { idx ->
|
||||
val options = listOf(pressOnceText, pressTwiceText)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
}
|
||||
}
|
||||
parentHoveredIndexDouble = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActiveDouble = false
|
||||
parentHoveredIndexDouble = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.hang_up),
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPositionDouble = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = doublePressAction,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDoublePressDropdown,
|
||||
onDismissRequest = {
|
||||
showDoublePressDropdown = false
|
||||
lastDismissTimeDouble = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(pressOnceText, pressTwiceText),
|
||||
selectedOption = doublePressAction,
|
||||
touchOffset = touchOffsetDouble,
|
||||
boxPosition = boxPositionDouble,
|
||||
externalHoveredIndex = parentHoveredIndexDouble,
|
||||
externalDragActive = parentDragActiveDouble,
|
||||
onOptionSelected = { option ->
|
||||
doublePressAction = option
|
||||
singlePressAction =
|
||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||
showDoublePressDropdown = false
|
||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
||||
0x00,
|
||||
0x02
|
||||
) else byteArrayOf(0x00, 0x03)
|
||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun CallControlSettingsPreview() {
|
||||
CallControlSettings(HazeState())
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConnectionSettings() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_ear_detection",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.automatically_connect),
|
||||
description = stringResource(R.string.automatically_connect_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConnectionSettingsPreview() {
|
||||
ConnectionSettings()
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val SelectedColorBlue = Color(0xFF0A84FF)
|
||||
private val UnselectedColor = Color(0x593C3C3E)
|
||||
private val TextColor = Color.White
|
||||
private val IconTint = Color.White
|
||||
|
||||
@Composable
|
||||
fun ControlCenterButton(
|
||||
label: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
iconAreaSize: Dp,
|
||||
isSelected: Boolean,
|
||||
backgroundBrush: Brush? = null
|
||||
) {
|
||||
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = targetBackgroundColor,
|
||||
label = "ButtonBackground"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(iconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = IconTint,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = TextColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
-242
@@ -1,242 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
|
||||
private val ContainerColor = Color(0x593C3C3E)
|
||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
|
||||
private val TextColor = Color.White
|
||||
private val IconTintUnselected = Color.White
|
||||
private val IconTintSelected = Color.White
|
||||
|
||||
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
|
||||
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
|
||||
Color(0xFFB03A2F)
|
||||
)
|
||||
)
|
||||
|
||||
internal val IconAreaSize = 72.dp
|
||||
private val IconSize = 42.dp
|
||||
private val IconRowHeight = IconAreaSize + 12.dp
|
||||
private val TextRowHeight = 24.dp
|
||||
private val TextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
fun ControlCenterNoiseControlSegmentedButton(
|
||||
modifier: Modifier = Modifier,
|
||||
availableModes: List<NoiseControlMode>,
|
||||
selectedMode: NoiseControlMode,
|
||||
onModeSelected: (NoiseControlMode) -> Unit
|
||||
) {
|
||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||
val density = LocalDensity.current
|
||||
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||
val itemCount = availableModes.size
|
||||
|
||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||
if (itemCount > 0 && iconRowWidthPx > 0) {
|
||||
iconRowWidthPx / itemCount
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
|
||||
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
|
||||
|
||||
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
|
||||
if (itemSlotWidthPx > 0) {
|
||||
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
|
||||
slotCenterPx - (iconAreaSizePx / 2f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
val indicatorOffset: Dp by animateDpAsState(
|
||||
targetValue = with(density) { targetIndicatorStartPx.toDp() },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "IndicatorOffset"
|
||||
)
|
||||
|
||||
val indicatorBackground = remember(selectedMode) {
|
||||
when (selectedMode) {
|
||||
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
|
||||
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IconRowHeight)
|
||||
.clip(CircleShape)
|
||||
.background(ContainerColor)
|
||||
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = indicatorOffset)
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorBackground)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
NoiseControlIconItem(
|
||||
modifier = Modifier.size(IconAreaSize),
|
||||
mode = mode,
|
||||
isSelected = isSelected,
|
||||
onClick = { onModeSelected(mode) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TextRowHeight),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
Text(
|
||||
text = getModeLabel(mode),
|
||||
color = TextColor,
|
||||
fontSize = TextSize,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoiseControlIconItem(
|
||||
modifier: Modifier = Modifier,
|
||||
mode: NoiseControlMode,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val iconRes = remember(mode) { getModeIconRes(mode) }
|
||||
|
||||
val tint = IconTintUnselected
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = getModeLabel(mode),
|
||||
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
|
||||
modifier = Modifier.size(IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
|
||||
}
|
||||
}
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun MicrophoneSettings(hazeState: HazeState) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(top = 2.dp)
|
||||
) {
|
||||
val service = ServiceManager.getService()!!
|
||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
}?.value?.get(0) ?: 0x00.toByte()
|
||||
|
||||
var selectedMode by remember {
|
||||
mutableStateOf(
|
||||
when (micModeValue) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
)
|
||||
}
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
val reopenThresholdMs = 250L
|
||||
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||
) {
|
||||
selectedMode = when (controlCommand.value[0]) {
|
||||
0x00.toByte() -> "Automatic"
|
||||
0x01.toByte() -> "Always Right"
|
||||
0x02.toByte() -> "Always Left"
|
||||
else -> "Automatic"
|
||||
}
|
||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
||||
listener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (showDropdown) {
|
||||
showDropdown = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > reopenThresholdMs) {
|
||||
touchOffset = offset
|
||||
showDropdown = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
|
||||
showDropdown = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndex = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
val options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
)
|
||||
if (idx in options.indices) {
|
||||
val option = options[idx]
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
val byteValue = when (option) {
|
||||
options[0] -> 0x00
|
||||
options[1] -> 0x01
|
||||
options[2] -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
byteArrayOf(byteValue.toByte())
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.microphone_mode),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedMode,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||
|
||||
StyledDropdown(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = {
|
||||
showDropdown = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = listOf(
|
||||
microphoneAutomaticText,
|
||||
microphoneAlwaysRightText,
|
||||
microphoneAlwaysLeftText
|
||||
),
|
||||
selectedOption = selectedMode,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
selectedMode = option
|
||||
showDropdown = false
|
||||
val byteValue = when (option) {
|
||||
microphoneAutomaticText -> 0x00
|
||||
microphoneAlwaysRightText -> 0x01
|
||||
microphoneAlwaysLeftText -> 0x02
|
||||
else -> 0x00
|
||||
}
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||
byteArrayOf(byteValue.toByte())
|
||||
)
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Preview
|
||||
@Composable
|
||||
fun MicrophoneSettingsPreview() {
|
||||
MicrophoneSettings(HazeState())
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@Composable
|
||||
fun NavigationButton(
|
||||
to: String,
|
||||
name: String,
|
||||
navController: NavController, onClick: (() -> Unit)? = null,
|
||||
independent: Boolean = true,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
currentState: String? = null,
|
||||
height: Dp = 58.dp,
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
Column {
|
||||
if (title != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
|
||||
.height(height)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
},
|
||||
onTap = {
|
||||
if (onClick != null) onClick() else navController.navigate(to)
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (currentState != null) {
|
||||
Text(
|
||||
text = currentState,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = if (currentState != null) 6.dp else 0.dp)
|
||||
)
|
||||
}
|
||||
if (description != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
// modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NavigationButtonPreview() {
|
||||
NavigationButton("to", "Name", NavController(LocalContext.current))
|
||||
}
|
||||
-444
@@ -1,444 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun NoiseControlSettings(
|
||||
service: AirPodsService,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||
|
||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
offListeningModeListener
|
||||
)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||
|
||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||
|
||||
val d1a = remember { mutableFloatStateOf(0f) }
|
||||
val d2a = remember { mutableFloatStateOf(0f) }
|
||||
val d3a = remember { mutableFloatStateOf(0f) }
|
||||
|
||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||
val previousMode = noiseControlMode.value
|
||||
|
||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||
NoiseControlMode.TRANSPARENCY
|
||||
} else {
|
||||
mode
|
||||
}
|
||||
|
||||
noiseControlMode.value = targetMode
|
||||
|
||||
if (!received && targetMode != previousMode) {
|
||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||
}
|
||||
|
||||
when (noiseControlMode.value) {
|
||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.OFF -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 1f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
NoiseControlMode.ADAPTIVE -> {
|
||||
d1a.floatValue = 1f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 0f
|
||||
}
|
||||
NoiseControlMode.TRANSPARENCY -> {
|
||||
d1a.floatValue = 0f
|
||||
d2a.floatValue = 0f
|
||||
d3a.floatValue = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val noiseControlReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
||||
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
|
||||
onModeSelected(noiseControlMode.value, true)
|
||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val noiseControlIntentFilter = IntentFilter().apply {
|
||||
addAction(AirPodsNotifications.ANC_DATA)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
)
|
||||
)
|
||||
}
|
||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val buttonCount = if (offListeningMode.value) 4 else 3
|
||||
val buttonWidth = maxWidth / buttonCount
|
||||
|
||||
val isDragging = remember { mutableStateOf(false) }
|
||||
var dragOffset by remember {
|
||||
mutableFloatStateOf(
|
||||
with(density) {
|
||||
when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
|
||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
|
||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val animationSpec: AnimationSpec<Float> = SpringSpec(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = 0.01f
|
||||
)
|
||||
|
||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
|
||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
|
||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
|
||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
|
||||
}
|
||||
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = with(density) {
|
||||
if (isDragging.value) dragOffset else targetOffset.toPx()
|
||||
},
|
||||
animationSpec = animationSpec,
|
||||
label = "selector"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(buttonWidth)
|
||||
.fillMaxHeight()
|
||||
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||
.zIndex(0f)
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
state = rememberDraggableState { delta ->
|
||||
dragOffset = (dragOffset + delta).coerceIn(
|
||||
0f,
|
||||
with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
|
||||
)
|
||||
},
|
||||
onDragStarted = { isDragging.value = true },
|
||||
onDragStopped = {
|
||||
isDragging.value = false
|
||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
||||
val newIndex = position.roundToInt()
|
||||
val newMode = when(newIndex) {
|
||||
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||
else -> noiseControlMode.value // Keep current if index is invalid
|
||||
}
|
||||
// Call onModeSelected which now handles service call but not callback
|
||||
onModeSelected(newMode)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(3.dp)
|
||||
.background(selectedBackground, RoundedCornerShape(26.dp))
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d1a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d2a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
.alpha(d3a.floatValue),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
usePadding = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (offListeningMode.value) {
|
||||
Text(
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
NoiseControlSettings(AirPodsService())
|
||||
}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = Color(0x40888888)
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
|
||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp))
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
) {
|
||||
NavigationButton(
|
||||
to = "long_press/Left",
|
||||
name = stringResource(R.string.left),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = leftActionText,
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = dividerColor,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "long_press/Right",
|
||||
name = stringResource(R.string.right),
|
||||
navController = navController,
|
||||
independent = false,
|
||||
currentState = rightActionText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PressAndHoldSettingsPreview() {
|
||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refraction
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
onClick: () -> Unit,
|
||||
backdrop: Backdrop,
|
||||
modifier: Modifier = Modifier,
|
||||
isInteractive: Boolean = true,
|
||||
tint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
maxScale: Float = 0.1f,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier
|
||||
.then(
|
||||
if (!isInteractive) {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
blur(16f.dp.toPx())
|
||||
},
|
||||
layerBlock = null,
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = null,
|
||||
highlight = { Highlight.Ambient.copy(alpha = 0f) }
|
||||
)
|
||||
} else {
|
||||
Modifier.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(28f.dp) },
|
||||
effects = {
|
||||
vibrancy()
|
||||
blur(2f.dp.toPx())
|
||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1f + maxScale, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
if (tint.isSpecified) {
|
||||
drawRect(tint, blendMode = BlendMode.Hue)
|
||||
drawRect(tint.copy(alpha = 0.75f))
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
blue = surfaceColor.blue * 0.5f,
|
||||
alpha = surfaceColor.alpha
|
||||
)
|
||||
} else {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
}
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
onClick = onClick
|
||||
)
|
||||
.then(
|
||||
if (isInteractive) {
|
||||
Modifier.pointerInput(animationScope) {
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPressed = true
|
||||
tryAwaitRelease()
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
.height(48f.dp)
|
||||
.padding(horizontal = 16f.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import dev.chrisbanes.haze.HazeEffectScope
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledDropdown(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
touchOffset: Offset?,
|
||||
boxPosition: Offset,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
externalHoveredIndex: Int? = null,
|
||||
externalDragActive: Boolean = false,
|
||||
hazeState: HazeState,
|
||||
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
|
||||
) {
|
||||
if (expanded) {
|
||||
val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero
|
||||
Popup(
|
||||
offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.width(300.dp)
|
||||
.background(Color.Transparent)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
val itemHeight = 48.dp
|
||||
|
||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
LaunchedEffect(externalHoveredIndex, externalDragActive) {
|
||||
if (externalDragActive) {
|
||||
hoveredIndex = externalHoveredIndex
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
popupSize = coordinates.size
|
||||
}
|
||||
.pointerInput(popupSize) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
hoveredIndex = (offset.y / itemHeight.toPx()).toInt()
|
||||
lastDragPosition = offset
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val y = change.position.y
|
||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||
lastDragPosition = change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
val pos = lastDragPosition
|
||||
val withinBounds = pos != null &&
|
||||
pos.x >= 0f && pos.y >= 0f &&
|
||||
pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat()
|
||||
|
||||
if (withinBounds) {
|
||||
hoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
}
|
||||
}
|
||||
onDismissRequest()
|
||||
} else {
|
||||
hoveredIndex = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
options.forEachIndexed { index, text ->
|
||||
val isHovered =
|
||||
if (externalDragActive && externalHoveredIndex != null) {
|
||||
index == externalHoveredIndex
|
||||
} else {
|
||||
index == hoveredIndex
|
||||
}
|
||||
val isSystemInDarkTheme = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(itemHeight)
|
||||
.background(
|
||||
Color.Transparent
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onOptionSelected(text)
|
||||
onDismissRequest()
|
||||
}
|
||||
.hazeEffect(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.regular(),
|
||||
block = fun HazeEffectScope.() {
|
||||
alpha = 1f
|
||||
backgroundColor = if (isSystemInDarkTheme) {
|
||||
Color(0xB02C2C2E)
|
||||
} else {
|
||||
Color(0xB0FFFFFF)
|
||||
}
|
||||
tints = if (isHovered) listOf(
|
||||
HazeTint(
|
||||
color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
|
||||
)
|
||||
) else listOf()
|
||||
})
|
||||
.padding(horizontal = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Checkbox(
|
||||
checked = text == selectedOption,
|
||||
onCheckedChange = { onOptionSelected(text) },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (index != options.lastIndex) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ShaderBrush
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.tanh
|
||||
|
||||
@Composable
|
||||
fun StyledIconButton(
|
||||
onClick: () -> Unit,
|
||||
icon: String,
|
||||
darkMode: Boolean,
|
||||
tint: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||
compositingStrategy = CompositingStrategy.Offscreen
|
||||
}
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
uniform float radius;
|
||||
uniform float2 offset;
|
||||
|
||||
half4 main(float2 coord) {
|
||||
float2 center = offset;
|
||||
float dist = distance(coord, center);
|
||||
float intensity = smoothstep(radius, radius * 0.5, dist);
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(56.dp),
|
||||
modifier = modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.drawBackdrop(
|
||||
backdrop = backdrop,
|
||||
shape = { RoundedCornerShape(56.dp) },
|
||||
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 12f.dp,
|
||||
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
|
||||
val progress = progressAnimation.value
|
||||
val scale = lerp(1f, 1.5f, progress)
|
||||
|
||||
val maxOffset = size.minDimension
|
||||
val initialDerivative = 0.05f
|
||||
val offset = offsetAnimation.value
|
||||
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
||||
|
||||
val maxDragScale = 0.1f
|
||||
val offsetAngle = atan2(offset.y, offset.x)
|
||||
scaleX =
|
||||
scale +
|
||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||
(width / height).fastCoerceAtMost(1f)
|
||||
scaleY =
|
||||
scale +
|
||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||
(height / width).fastCoerceAtMost(1f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||
|
||||
val shape = RoundedCornerShape(56.dp)
|
||||
val outline = shape.createOutline(size, layoutDirection, this)
|
||||
val innerShadowOffset = 4f.dp.toPx()
|
||||
val innerShadowBlurRadius = 4f.dp.toPx()
|
||||
|
||||
innerShadowLayer.alpha = progress
|
||||
innerShadowLayer.renderEffect =
|
||||
BlurEffect(
|
||||
innerShadowBlurRadius,
|
||||
innerShadowBlurRadius,
|
||||
TileMode.Decal
|
||||
)
|
||||
innerShadowLayer.record {
|
||||
drawOutline(outline, Color.Black.copy(0.2f))
|
||||
translate(0f, innerShadowOffset) {
|
||||
drawOutline(
|
||||
outline,
|
||||
Color.Transparent,
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLayer(innerShadowLayer)
|
||||
|
||||
drawRect(
|
||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
||||
)
|
||||
},
|
||||
onDrawFront = {
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
||||
// blur(24f, TileMode.Decal)
|
||||
},
|
||||
)
|
||||
.pointerInput(animationScope) {
|
||||
val onDragStop: () -> Unit = {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||
}
|
||||
}
|
||||
inspectDragGestures(
|
||||
onDragStart = { down ->
|
||||
pressStartPosition = down.position
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||
}
|
||||
},
|
||||
onDragEnd = { onDragStop() },
|
||||
onDragCancel = onDragStop
|
||||
) { _, dragAmount ->
|
||||
animationScope.launch {
|
||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
.size(48.dp),
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
Scaffold(
|
||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) { paddingValues ->
|
||||
val topPadding = paddingValues.calculateTopPadding()
|
||||
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
|
||||
) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.height(64.dp + topPadding)
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeEffect(state = hazeState) {
|
||||
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
|
||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
actionButtons.forEach { actionButton ->
|
||||
actionButton(backdrop)
|
||||
}
|
||||
}
|
||||
|
||||
content(topPadding + 64.dp, hazeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { _, _ ->
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledScaffold(
|
||||
title: String,
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (spacerValue: Dp) -> Unit
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = title,
|
||||
actionButtons = actionButtons,
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { spacerValue, _ ->
|
||||
content(spacerValue)
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
data class SelectItem(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
data class SelectItem2(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val iconRes: Int? = null,
|
||||
val selected: () -> Boolean,
|
||||
val onClick: () -> Unit,
|
||||
val enabled: Boolean = true
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
fun StyledSelectList(
|
||||
items: List<SelectItem>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val visibleItems = items.filter { it.enabled }
|
||||
visibleItems.forEachIndexed { index, item ->
|
||||
val isFirst = index == 0
|
||||
val isLast = index == visibleItems.size - 1
|
||||
val hasIcon = item.iconRes != null
|
||||
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(if (hasIcon) 72.dp else 55.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
itemBackgroundColor = backgroundColor
|
||||
item.onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (hasIcon) {
|
||||
Icon(
|
||||
painter = painterResource(item.iconRes!!),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = if (hasIcon) 8.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
item.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
item.description?.let {
|
||||
Text(
|
||||
it,
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
)
|
||||
}
|
||||
}
|
||||
val floatAnimateState by animateFloatAsState(
|
||||
targetValue = if (item.selected) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
|
||||
),
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
if (!isLast) {
|
||||
if (hasIcon) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 72.dp, end = 20.dp)
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableFloatState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.inspectDragGestures
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun rememberMomentumAnimation(
|
||||
maxScale: Float,
|
||||
progressAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(1f, 1000f, 0.01f),
|
||||
velocityAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.5f, 250f, 5f),
|
||||
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.4f, 400f, 0.01f),
|
||||
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
|
||||
spring(0.6f, 400f, 0.01f)
|
||||
): MomentumAnimation {
|
||||
val animationScope = rememberCoroutineScope()
|
||||
return remember(
|
||||
maxScale,
|
||||
animationScope,
|
||||
progressAnimationSpec,
|
||||
velocityAnimationSpec,
|
||||
scaleXAnimationSpec,
|
||||
scaleYAnimationSpec
|
||||
) {
|
||||
MomentumAnimation(
|
||||
maxScale = maxScale,
|
||||
animationScope = animationScope,
|
||||
progressAnimationSpec = progressAnimationSpec,
|
||||
velocityAnimationSpec = velocityAnimationSpec,
|
||||
scaleXAnimationSpec = scaleXAnimationSpec,
|
||||
scaleYAnimationSpec = scaleYAnimationSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MomentumAnimation(
|
||||
val maxScale: Float,
|
||||
private val animationScope: CoroutineScope,
|
||||
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
|
||||
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
|
||||
) {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
|
||||
private val progressAnimation = Animatable(0f)
|
||||
private val velocityAnimation = Animatable(0f)
|
||||
private val scaleXAnimation = Animatable(1f)
|
||||
private val scaleYAnimation = Animatable(1f)
|
||||
|
||||
val progress: Float get() = progressAnimation.value
|
||||
val velocity: Float get() = velocityAnimation.value
|
||||
val scaleX: Float get() = scaleXAnimation.value
|
||||
val scaleY: Float get() = scaleYAnimation.value
|
||||
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val modifier: Modifier = Modifier.pointerInput(Unit) {
|
||||
inspectDragGestures(
|
||||
onDragStart = {
|
||||
isDragging = true
|
||||
velocityTracker.resetTracking()
|
||||
startPressingAnimation()
|
||||
},
|
||||
onDragEnd = { change ->
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
velocityTracker.resetTracking()
|
||||
endPressingAnimation()
|
||||
settleVelocity()
|
||||
}
|
||||
) { change, _ ->
|
||||
isDragging = true
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
updateVelocity(velocity)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateVelocity(velocity: Velocity) {
|
||||
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
private fun settleVelocity() {
|
||||
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
|
||||
}
|
||||
|
||||
fun startPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
|
||||
fun endPressingAnimation() {
|
||||
animationScope.launch {
|
||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
|
||||
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StyledSlider(
|
||||
label: String? = null,
|
||||
mutableFloatState: MutableFloatState,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
snapPoints: List<Float> = emptyList(),
|
||||
snapThreshold: Float = 0.05f,
|
||||
startIcon: String? = null,
|
||||
endIcon: String? = null,
|
||||
startLabel: String? = null,
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null
|
||||
) {
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isLightTheme = !isSystemInDarkTheme()
|
||||
val accentColor =
|
||||
if (isLightTheme) Color(0xFF0088FF)
|
||||
else Color(0xFF0091FF)
|
||||
val trackColor =
|
||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||
else Color(0xFF787880).copy(0.36f)
|
||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||
|
||||
val fraction by remember {
|
||||
derivedStateOf {
|
||||
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val sliderBackdrop = rememberLayerBackdrop()
|
||||
val trackWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val trackPositionState = remember { mutableFloatStateOf(0f) }
|
||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||
|
||||
val content = @Composable {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.layerBackdrop(sliderBackdrop)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (startLabel != null || endLabel != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = startLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = endLabel ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
if (startIcon != null) {
|
||||
Text(
|
||||
text = startIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
startIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
|
||||
.onGloballyPositioned {
|
||||
trackPositionState.floatValue =
|
||||
it.positionInParent().y + it.size.height / 2f
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(trackColor)
|
||||
.height(6f.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
.background(accentColor)
|
||||
.height(6f.dp)
|
||||
.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val fraction = fraction
|
||||
val width =
|
||||
(fraction * constraints.maxWidth).fastRoundToInt()
|
||||
layout(width, placeable.height) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (endIcon != null) {
|
||||
Text(
|
||||
text = endIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.onGloballyPositioned {
|
||||
endIconWidthState.floatValue = it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() }
|
||||
val startOffset =
|
||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||
density
|
||||
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
snapPoints.forEach { point ->
|
||||
val pointFraction =
|
||||
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
.fastCoerceIn(0f, 1f)
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
translationX =
|
||||
startOffset + pointFraction * trackWidth - 4.dp.toPx()
|
||||
}
|
||||
.size(2.dp)
|
||||
.background(
|
||||
trackColor,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
// val startOffset =
|
||||
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
|
||||
// translationX =
|
||||
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
|
||||
val startOffset =
|
||||
if (startIcon != null)
|
||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||
else
|
||||
with(density) { 8.dp.toPx() }
|
||||
|
||||
translationX =
|
||||
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
|
||||
.fastCoerceIn(
|
||||
startOffset - size.width / 4f,
|
||||
startOffset + trackWidthState.floatValue - size.width * 3f / 4f
|
||||
)
|
||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||
}
|
||||
.draggable(
|
||||
rememberDraggableState { delta ->
|
||||
val trackWidth = trackWidthState.floatValue
|
||||
if (trackWidth > 0f) {
|
||||
val targetFraction = fraction + delta / trackWidth
|
||||
val targetValue =
|
||||
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
|
||||
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
|
||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||
targetValue,
|
||||
snapPoints,
|
||||
snapThreshold
|
||||
) else targetValue
|
||||
onValueChange(snappedValue)
|
||||
}
|
||||
},
|
||||
Orientation.Horizontal,
|
||||
startDragImmediately = true,
|
||||
onDragStarted = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
},
|
||||
onDragStopped = {
|
||||
// Remove this block as momentumAnimation handles pressing
|
||||
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
|
||||
}
|
||||
)
|
||||
.then(momentumAnimation.modifier)
|
||||
.drawBackdrop(
|
||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||
{ RoundedCornerShape(28.dp) },
|
||||
highlight = {
|
||||
val progress = momentumAnimation.progress
|
||||
Highlight.Ambient.copy(alpha = progress)
|
||||
},
|
||||
shadow = {
|
||||
Shadow(
|
||||
radius = 4f.dp,
|
||||
color = Color.Black.copy(0.05f)
|
||||
)
|
||||
},
|
||||
innerShadow = {
|
||||
val progress = momentumAnimation.progress
|
||||
InnerShadow(
|
||||
radius = 4f.dp * progress,
|
||||
alpha = progress
|
||||
)
|
||||
},
|
||||
layerBlock = {
|
||||
scaleX = momentumAnimation.scaleX
|
||||
scaleY = momentumAnimation.scaleY
|
||||
val velocity = momentumAnimation.velocity / 5000f
|
||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||
},
|
||||
onDrawSurface = {
|
||||
val progress = momentumAnimation.progress
|
||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||
},
|
||||
effects = {
|
||||
val progress = momentumAnimation.progress
|
||||
blur(8f.dp.toPx() * (1f - progress))
|
||||
refractionWithDispersion(
|
||||
height = 6f.dp.toPx() * progress,
|
||||
amount = size.height / 2f * progress
|
||||
)
|
||||
}
|
||||
)
|
||||
.size(40f.dp, 24f.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (independent) {
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelTextColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
.heightIn(min = 58.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
|
||||
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
||||
val nearest = points.minByOrNull { abs(it - value) } ?: value
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Box (
|
||||
Modifier.align(Alignment.Center)
|
||||
)
|
||||
{
|
||||
StyledSlider(
|
||||
mutableFloatState = a,
|
||||
onValueChange = {
|
||||
a.floatValue = it
|
||||
},
|
||||
valueRange = 0f..2f,
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,687 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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
|
||||
)
|
||||
}
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
fun VerticalVolumeSlider(
|
||||
displayFraction: Float,
|
||||
maxVolume: Int,
|
||||
onVolumeChange: (Int) -> Unit,
|
||||
initialFraction: Float,
|
||||
onDragStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
baseSliderHeight: Dp = 400.dp,
|
||||
baseSliderWidth: Dp = 145.dp,
|
||||
baseCornerRadius: Dp = 45.dp,
|
||||
maxStretchFactor: Float = 1.15f,
|
||||
minCompressionFactor: Float = 0.875f,
|
||||
stretchSensitivity: Float = 1.0f,
|
||||
compressionSensitivity: Float = 1.0f,
|
||||
cornerRadiusChangeFactor: Float = 0.2f,
|
||||
directionalStretchRatio: Float = 0.75f
|
||||
) {
|
||||
val trackColor = Color(0x593C3C3E)
|
||||
val progressColor = Color.White
|
||||
|
||||
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
|
||||
var overscrollAmount by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = dragFraction.coerceIn(0f, 1f),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "ProgressAnimation"
|
||||
)
|
||||
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = overscrollAmount,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "OverscrollAnimation"
|
||||
)
|
||||
|
||||
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
|
||||
|
||||
val stretchMultiplier = stretchSensitivity
|
||||
val compressionMultiplier = compressionSensitivity
|
||||
|
||||
val overscrollDirection = sign(animatedOverscroll)
|
||||
|
||||
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
|
||||
|
||||
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
|
||||
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
|
||||
(-overscrollDirection * asymmetricOffset.value).dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
|
||||
val heightStretch = baseSliderHeight + totalStretchAmount
|
||||
|
||||
val widthCompression = baseSliderWidth * max(
|
||||
minCompressionFactor,
|
||||
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
|
||||
)
|
||||
|
||||
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(heightStretch)
|
||||
.width(widthCompression)
|
||||
.offset(y = offsetY)
|
||||
.clip(RoundedCornerShape(dynamicCornerRadius))
|
||||
.background(trackColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
|
||||
dragFraction = newFraction
|
||||
rawDragPosition = newFraction
|
||||
overscrollAmount = 0f
|
||||
|
||||
val newVolume = (newFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
rawDragPosition -= (delta / baseHeightPx)
|
||||
|
||||
dragFraction = rawDragPosition.coerceIn(0f, 1f)
|
||||
|
||||
overscrollAmount = when {
|
||||
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
|
||||
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val newVolume = (dragFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
},
|
||||
onDragStarted = {
|
||||
isDragging = true
|
||||
dragFraction = displayFraction
|
||||
rawDragPosition = displayFraction
|
||||
overscrollAmount = 0f
|
||||
onDragStateChange(true)
|
||||
},
|
||||
onDragStopped = {
|
||||
isDragging = false
|
||||
overscrollAmount = 0f
|
||||
rawDragPosition = dragFraction
|
||||
onDragStateChange(false)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(animatedProgress)
|
||||
.background(progressColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.constants
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
const val LEFT = 4
|
||||
const val RIGHT = 2
|
||||
const val CASE = 8
|
||||
}
|
||||
|
||||
object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
|
||||
fun getComponentName(): String? {
|
||||
return when (component) {
|
||||
BatteryComponent.LEFT -> "LEFT"
|
||||
BatteryComponent.RIGHT -> "RIGHT"
|
||||
BatteryComponent.CASE -> "CASE"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusName(): String? {
|
||||
return when (status) {
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class NoiseControlMode {
|
||||
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
|
||||
}
|
||||
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
|
||||
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = Capabilities.EAR_DETECTION
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = listOf(data[6], data[7])
|
||||
}
|
||||
|
||||
fun isEarDetectionData(data: ByteArray): Boolean {
|
||||
if (data.size != 8) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ANC {
|
||||
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
|
||||
|
||||
var status: Int = 1
|
||||
private set
|
||||
|
||||
fun isANCData(data: ByteArray): Boolean {
|
||||
if (data.size != 11) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
when (data.size) {
|
||||
// if the whole packet is given
|
||||
11 -> {
|
||||
status = data[7].toInt()
|
||||
}
|
||||
// if only the data is given
|
||||
1 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
// if the value of control command is given
|
||||
4 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
else -> {
|
||||
Log.d("ANC", "Invalid ANC data size: ${data.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
4 -> "ADAPTIVE"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BatteryNotification {
|
||||
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
|
||||
|
||||
fun isBatteryData(data: ByteArray): Boolean {
|
||||
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
|
||||
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
if (data.size != 22) {
|
||||
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
|
||||
return false
|
||||
}
|
||||
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
|
||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||
}
|
||||
|
||||
fun setBatteryDirect(
|
||||
leftLevel: Int,
|
||||
leftCharging: Boolean,
|
||||
rightLevel: Int,
|
||||
rightCharging: Boolean,
|
||||
caseLevel: Int,
|
||||
caseCharging: Boolean
|
||||
) {
|
||||
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
}
|
||||
|
||||
fun setBattery(data: ByteArray) {
|
||||
if (data.size != 22) {
|
||||
return
|
||||
}
|
||||
// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(first.component, first.level, data[10].toInt())
|
||||
// } else {
|
||||
// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
|
||||
// }
|
||||
// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(second.component, second.level, data[15].toInt())
|
||||
// } else {
|
||||
// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
|
||||
// }
|
||||
// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
|
||||
// Battery(case.component, case.level, data[20].toInt())
|
||||
// } else {
|
||||
// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
|
||||
// }
|
||||
// sometimes it shows battery as -1%, just skip all that and set it normally
|
||||
first = Battery(
|
||||
data[7].toInt(), data[9].toInt(), data[10].toInt()
|
||||
)
|
||||
second = Battery(
|
||||
data[12].toInt(), data[14].toInt(), data[15].toInt()
|
||||
)
|
||||
case = Battery(
|
||||
data[17].toInt(), data[19].toInt(), data[20].toInt()
|
||||
)
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
val prefixPattern = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00
|
||||
)
|
||||
|
||||
for (i in prefixPattern.indices) {
|
||||
if (data[i] != prefixPattern[i]) return false
|
||||
}
|
||||
|
||||
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
|
||||
|
||||
if (data[11] != 0x00.toByte()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
-840
@@ -1,840 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledDropdown
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var phoneMediaDebounceJob: Job? = null
|
||||
private var toneVolumeDebounceJob: Job? = null
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AccessibilitySettingsScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val isSdpOffsetAvailable = remember { mutableStateOf(true) } // always available rn, for testing without radare
|
||||
// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
|
||||
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
|
||||
|
||||
val hearingAidEnabled = remember { mutableStateOf(
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
|
||||
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
|
||||
) }
|
||||
|
||||
val hearingAidListener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.accessibility)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
val selectedPressSpeedValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
selectedPressSpeedListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
selectedPressSpeedListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
val selectedPressAndHoldDurationValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedPressAndHoldDuration =
|
||||
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
selectedPressAndHoldDurationListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
selectedPressAndHoldDurationListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to stringResource(R.string.default_option),
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
3.toByte() to stringResource(R.string.longest)
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
|
||||
?.get(0)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
|
||||
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
|
||||
selectedVolumeSwipeSpeed =
|
||||
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
aacpManager?.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
selectedVolumeSwipeSpeedListener
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
aacpManager?.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
selectedVolumeSwipeSpeedListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
val manager = ServiceManager.getService()?.aacpManager
|
||||
if (manager == null) {
|
||||
Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val toneVolumeValue = remember { mutableFloatStateOf(
|
||||
aacpManager?.controlCommandStatusList?.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
|
||||
) }
|
||||
LaunchedEffect(toneVolumeValue.floatValue) {
|
||||
toneVolumeDebounceJob?.cancel()
|
||||
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
val manager = ServiceManager.getService()?.aacpManager
|
||||
if (manager == null) {
|
||||
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
manager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
|
||||
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending tone volume: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description),
|
||||
options = pressSpeedOptions.values.toList(),
|
||||
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressSpeed = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_and_hold_duration),
|
||||
description = stringResource(R.string.press_and_hold_duration_description),
|
||||
options = pressAndHoldDurationOptions.values.toList(),
|
||||
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedPressAndHoldDuration = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 0.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
||||
independent = true,
|
||||
)
|
||||
|
||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
||||
NavigationButton(
|
||||
to = "transparency_customization",
|
||||
name = stringResource(R.string.customize_transparency_mode),
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
mutableFloatState = toneVolumeValue,
|
||||
onValueChange = {
|
||||
toneVolumeValue.floatValue = it
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startIcon = "\uDBC0\uDEA1",
|
||||
endIcon = "\uDBC0\uDEA9",
|
||||
independent = true
|
||||
)
|
||||
|
||||
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
||||
)
|
||||
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description),
|
||||
options = volumeSwipeSpeedOptions.values.toList(),
|
||||
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
|
||||
onOptionSelected = { newValue ->
|
||||
selectedVolumeSwipeSpeed = newValue
|
||||
aacpManager?.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
|
||||
?: 1.toByte()
|
||||
)
|
||||
},
|
||||
textColor = textColor,
|
||||
hazeState = hazeState,
|
||||
independent = true
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
|
||||
// Text(
|
||||
// text = stringResource(R.string.apply_eq_to),
|
||||
// style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = textColor.copy(alpha = 0.6f),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
// ),
|
||||
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .padding(vertical = 0.dp)
|
||||
// ) {
|
||||
// val darkModeLocal = isSystemInDarkTheme()
|
||||
//
|
||||
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||
// var phoneBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val phoneAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = phoneBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(phoneAnimatedBackgroundColor, phoneShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// tryAwaitRelease()
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// phoneEQEnabled.value = !phoneEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.phone),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = phoneEQEnabled.value,
|
||||
// onCheckedChange = { phoneEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888)
|
||||
// )
|
||||
//
|
||||
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
// var mediaBackgroundColor by remember {
|
||||
// mutableStateOf(
|
||||
// if (darkModeLocal) Color(
|
||||
// 0xFF1C1C1E
|
||||
// ) else Color(0xFFFFFFFF)
|
||||
// )
|
||||
// }
|
||||
// val mediaAnimatedBackgroundColor by animateColorAsState(
|
||||
// targetValue = mediaBackgroundColor,
|
||||
// animationSpec = tween(durationMillis = 500)
|
||||
// )
|
||||
//
|
||||
// Row(
|
||||
// modifier = Modifier
|
||||
// .height(48.dp)
|
||||
// .fillMaxWidth()
|
||||
// .background(mediaAnimatedBackgroundColor, mediaShape)
|
||||
// .pointerInput(Unit) {
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// tryAwaitRelease()
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// mediaEQEnabled.value = !mediaEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.media),
|
||||
// fontSize = 16.sp,
|
||||
// color = textColor,
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// modifier = Modifier.weight(1f)
|
||||
// )
|
||||
// Checkbox(
|
||||
// checked = mediaEQEnabled.value,
|
||||
// onCheckedChange = { mediaEQEnabled.value = it },
|
||||
// colors = CheckboxDefaults.colors().copy(
|
||||
// checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
// uncheckedCheckmarkColor = Color.Transparent,
|
||||
// checkedBoxColor = Color.Transparent,
|
||||
// uncheckedBoxColor = Color.Transparent,
|
||||
// checkedBorderColor = Color.Transparent,
|
||||
// uncheckedBorderColor = Color.Transparent
|
||||
// ),
|
||||
// modifier = Modifier
|
||||
// .height(24.dp)
|
||||
// .scale(1.5f)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// EQ Settings. Don't seem to have an effect?
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .padding(12.dp),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// for (i in 0 until 8) {
|
||||
// val eqPhoneValue =
|
||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
||||
// Row(
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(38.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// )
|
||||
|
||||
// Slider(
|
||||
// value = eqPhoneValue.floatValue,
|
||||
// onValueChange = { newVal ->
|
||||
// eqPhoneValue.floatValue = newVal
|
||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
||||
// newEQ[i] = eqPhoneValue.floatValue
|
||||
// phoneMediaEQ.value = newEQ
|
||||
// },
|
||||
// valueRange = 0f..100f,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(0.9f)
|
||||
// .height(36.dp),
|
||||
// colors = SliderDefaults.colors(
|
||||
// thumbColor = thumbColor,
|
||||
// activeTrackColor = activeTrackColor,
|
||||
// inactiveTrackColor = trackColor
|
||||
// ),
|
||||
// thumb = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(24.dp)
|
||||
// .shadow(4.dp, CircleShape)
|
||||
// .background(thumbColor, CircleShape)
|
||||
// )
|
||||
// },
|
||||
// track = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(12.dp),
|
||||
// contentAlignment = Alignment.CenterStart
|
||||
// )
|
||||
// {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(4.dp)
|
||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
|
||||
// .height(4.dp)
|
||||
// .background(activeTrackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// Text(
|
||||
// text = stringResource(R.string.band_label, i + 1),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(top = 4.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
private fun DropdownMenuComponent(
|
||||
label: String,
|
||||
options: List<String>,
|
||||
selectedOption: String,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
textColor: Color,
|
||||
hazeState: HazeState,
|
||||
description: String? = null,
|
||||
independent: Boolean = true
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var touchOffset by remember { mutableStateOf<Offset?>(null) }
|
||||
var boxPosition by remember { mutableStateOf(Offset.Zero) }
|
||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var parentDragActive by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()){
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (independent) {
|
||||
if (description != null) {
|
||||
Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
} else {
|
||||
Modifier.padding(vertical = 8.dp)
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
.background(
|
||||
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent,
|
||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||
)
|
||||
then(
|
||||
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
|
||||
)
|
||||
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
|
||||
){
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.height(58.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (expanded) {
|
||||
expanded = false
|
||||
lastDismissTime = now
|
||||
} else {
|
||||
if (now - lastDismissTime > 250L) {
|
||||
touchOffset = offset
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!expanded && now - lastDismissTime > 250L) {
|
||||
expanded = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
parentHoveredIndex = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
){
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (!independent && description != null){
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedOption,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
StyledDropdown(
|
||||
expanded = expanded,
|
||||
onDismissRequest = {
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
},
|
||||
options = options,
|
||||
selectedOption = selectedOption,
|
||||
touchOffset = touchOffset,
|
||||
boxPosition = boxPosition,
|
||||
externalHoveredIndex = parentHoveredIndex,
|
||||
externalDragActive = parentDragActive,
|
||||
onOptionSelected = { option ->
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
hazeState = hazeState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (independent && description != null){
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
){
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AdaptiveStrengthScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||
val service = ServiceManager.getService()!!
|
||||
|
||||
LaunchedEffect(sliderValue) {
|
||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||
}
|
||||
|
||||
val listener = remember {
|
||||
object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
|
||||
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
|
||||
sliderValue.floatValue = (100 - it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
service.aacpManager.registerControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
listener
|
||||
)
|
||||
onDispose {
|
||||
service.aacpManager.unregisterControlCommandListener(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
listener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_adaptive_audio)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
mutableFloatState = sliderValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
debounceJob?.cancel()
|
||||
debounceJob = CoroutineScope(Dispatchers.Default).launch {
|
||||
delay(300)
|
||||
service.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
||||
(100 - it).toInt()
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.AboutCard
|
||||
import me.kavishdevar.librepods.composables.AudioSettings
|
||||
import me.kavishdevar.librepods.composables.BatteryView
|
||||
import me.kavishdevar.librepods.composables.CallControlSettings
|
||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.composables.ConnectionSettings
|
||||
import me.kavishdevar.librepods.composables.HearingHealthSettings
|
||||
import me.kavishdevar.librepods.composables.MicrophoneSettings
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||
import me.kavishdevar.librepods.composables.StyledButton
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.Capability
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
isLocallyConnected = service.isConnectedLocally
|
||||
}
|
||||
|
||||
val nameChangeListener = remember {
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "name") {
|
||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
onDispose {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleRemoteConnection(connected: Boolean) {
|
||||
isRemotelyConnected = connected
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val connectionReceiver = remember {
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(true)
|
||||
}
|
||||
}
|
||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
||||
coroutineScope.launch {
|
||||
handleRemoteConnection(false)
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = true
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||
coroutineScope.launch {
|
||||
isLocallyConnected = false
|
||||
}
|
||||
}
|
||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(connectionReceiver, filter)
|
||||
}
|
||||
onDispose {
|
||||
try {
|
||||
context.unregisterReceiver(connectionReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(service) {
|
||||
service.let {
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
||||
})
|
||||
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
|
||||
putExtra("data", it.getANC())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
||||
|
||||
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
StyledScaffold(
|
||||
title = deviceName.text,
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = { navController.navigate("app_settings") },
|
||||
icon = "",
|
||||
darkMode = darkMode,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
snackbarHostState = snackbarHostState
|
||||
) { spacerHeight, hazeState ->
|
||||
hazeStateS.value = hazeState
|
||||
if (isLocallyConnected || isRemotelyConnected) {
|
||||
val instance = service.airpodsInstance
|
||||
if (instance == null) {
|
||||
Text("Error: AirPods instance is null")
|
||||
return@StyledScaffold
|
||||
}
|
||||
val capabilities = instance.model.capabilities
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
||||
item(key = "battery") {
|
||||
BatteryView(service = service)
|
||||
}
|
||||
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
|
||||
item(key = "name") {
|
||||
NavigationButton(
|
||||
to = "rename",
|
||||
name = stringResource(R.string.name),
|
||||
currentState = deviceName.text,
|
||||
navController = navController,
|
||||
independent = true
|
||||
)
|
||||
}
|
||||
// val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
|
||||
// if (actAsAppleDeviceHookEnabled) {
|
||||
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
item(key = "hearing_health") {
|
||||
HearingHealthSettings(navController = navController)
|
||||
}
|
||||
// }
|
||||
|
||||
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
||||
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
||||
}
|
||||
|
||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
||||
}
|
||||
|
||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
||||
|
||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
|
||||
}
|
||||
|
||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "audio") { AudioSettings(navController = navController) }
|
||||
|
||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "connection") { ConnectionSettings() }
|
||||
|
||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
||||
|
||||
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "sleep_detection") {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.sleep_detection),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
|
||||
}
|
||||
|
||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
||||
|
||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "off_listening") {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.off_listening_mode),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
description = stringResource(R.string.off_listening_mode_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||
item(key = "about") { AboutCard(navController = navController) }
|
||||
|
||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
||||
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
else {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBackdrop(
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
exportedBackdrop = backdrop,
|
||||
shape = { RoundedCornerShape(0.dp) },
|
||||
highlight = {
|
||||
Highlight.Ambient.copy(alpha = 0f)
|
||||
}
|
||||
)
|
||||
.hazeSource(hazeState)
|
||||
.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.airpods_not_connected),
|
||||
style = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.airpods_not_connected_description),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
StyledButton(
|
||||
onClick = { navController.navigate("troubleshooting") },
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StyledButton(
|
||||
onClick = {
|
||||
service.reconnectFromSavedMac()
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reconnect_to_last_device),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = stringResource(R.string.support_librepods),
|
||||
message = stringResource(R.string.support_dialog_description),
|
||||
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
|
||||
dismissText = stringResource(R.string.never_show_again),
|
||||
onConfirm = {
|
||||
val browserIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://github.com/sponsors/kavishdevar".toUri()
|
||||
)
|
||||
context.startActivity(browserIntent)
|
||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
||||
},
|
||||
onDismiss = {
|
||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
||||
},
|
||||
hazeState = hazeStateS.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AirPodsSettingsScreenPreview() {
|
||||
Column (
|
||||
modifier = Modifier.height(2000.dp)
|
||||
) {
|
||||
LibrePodsTheme (
|
||||
darkTheme = true
|
||||
) {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,981 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
//import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(navController: NavController) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val showResetDialog = remember { mutableStateOf(false) }
|
||||
val showIrkDialog = remember { mutableStateOf(false) }
|
||||
val showEncKeyDialog = remember { mutableStateOf(false) }
|
||||
val showCameraDialog = remember { mutableStateOf(false) }
|
||||
val irkValue = remember { mutableStateOf("") }
|
||||
val encKeyValue = remember { mutableStateOf("") }
|
||||
val cameraPackageValue = remember { mutableStateOf("") }
|
||||
val irkError = remember { mutableStateOf<String?>(null) }
|
||||
val encKeyError = remember { mutableStateOf<String?>(null) }
|
||||
val cameraPackageError = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||
val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null)
|
||||
|
||||
if (savedIrk != null) {
|
||||
try {
|
||||
val decoded = Base64.decode(savedIrk)
|
||||
irkValue.value = decoded.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
irkValue.value = ""
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
if (savedEncKey != null) {
|
||||
try {
|
||||
val decoded = Base64.decode(savedEncKey)
|
||||
encKeyValue.value = decoded.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
encKeyValue.value = ""
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
if (savedCameraPackage != null) {
|
||||
cameraPackageValue.value = savedCameraPackage
|
||||
}
|
||||
}
|
||||
|
||||
val showPhoneBatteryInWidget = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
||||
}
|
||||
val conversationalAwarenessPauseMusicEnabled = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
|
||||
}
|
||||
val relativeConversationalAwarenessVolumeEnabled = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
|
||||
}
|
||||
val openDialogForControlling = remember {
|
||||
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
|
||||
}
|
||||
val disconnectWhenNotWearing = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
||||
}
|
||||
|
||||
val takeoverWhenDisconnected = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
|
||||
}
|
||||
val takeoverWhenIdle = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
|
||||
}
|
||||
val takeoverWhenMusic = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
|
||||
}
|
||||
val takeoverWhenCall = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
|
||||
}
|
||||
|
||||
val takeoverWhenRingingCall = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
|
||||
}
|
||||
val takeoverWhenMediaStart = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
||||
}
|
||||
|
||||
val useAlternateHeadTrackingPackets = remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
||||
}
|
||||
|
||||
fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
val isProcessingSdp = remember { mutableStateOf(false) }
|
||||
// val actAsAppleDevice = remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = isProcessingSdp.value) {}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.app_settings)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(state = hazeState)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
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),
|
||||
checkedState = showPhoneBatteryInWidget,
|
||||
sharedPreferenceKey = "show_phone_battery_in_widget",
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
|
||||
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)
|
||||
) {
|
||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
||||
conversationalAwarenessPauseMusicEnabled.value = enabled
|
||||
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)}
|
||||
}
|
||||
|
||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
||||
relativeConversationalAwarenessVolumeEnabled.value = enabled
|
||||
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checkedState = conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = { updateConversationalAwarenessPauseMusic(it) },
|
||||
independent = false
|
||||
)
|
||||
|
||||
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),
|
||||
checkedState = relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) },
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) }
|
||||
LaunchedEffect(conversationalAwarenessVolume.floatValue) {
|
||||
sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) }
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
mutableFloatState = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue },
|
||||
independent = true
|
||||
)
|
||||
|
||||
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 = { showCameraDialog.value = true },
|
||||
independent = true,
|
||||
description = stringResource(R.string.camera_control_app_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.quick_settings_tile),
|
||||
label = stringResource(R.string.open_dialog_for_controlling),
|
||||
description = stringResource(R.string.open_dialog_for_controlling_description),
|
||||
checkedState = openDialogForControlling,
|
||||
onCheckedChange = {
|
||||
openDialogForControlling.value = it
|
||||
sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "activity") }
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checkedState = disconnectWhenNotWearing,
|
||||
sharedPreferenceKey = "disconnect_when_not_wearing",
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
|
||||
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),
|
||||
checkedState = takeoverWhenDisconnected,
|
||||
onCheckedChange = {
|
||||
takeoverWhenDisconnected.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
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),
|
||||
checkedState = takeoverWhenIdle,
|
||||
onCheckedChange = {
|
||||
takeoverWhenIdle.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_idle", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
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),
|
||||
checkedState = takeoverWhenMusic,
|
||||
onCheckedChange = {
|
||||
takeoverWhenMusic.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_music", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
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),
|
||||
checkedState = takeoverWhenCall,
|
||||
onCheckedChange = {
|
||||
takeoverWhenCall.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_call", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
checkedState = takeoverWhenRingingCall,
|
||||
onCheckedChange = {
|
||||
takeoverWhenRingingCall.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
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),
|
||||
checkedState = takeoverWhenMediaStart,
|
||||
onCheckedChange = {
|
||||
takeoverWhenMediaStart.value = it
|
||||
sharedPreferences.edit { putBoolean("takeover_when_media_start", it)}
|
||||
},
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable (
|
||||
onClick = { showIrkDialog.value = true },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.set_identity_resolving_key),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.set_identity_resolving_key_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable (
|
||||
onClick = { showEncKeyDialog.value = true },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.set_encryption_key),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.set_encryption_key_description),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checkedState = useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = {
|
||||
useAlternateHeadTrackingPackets.value = it
|
||||
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)}
|
||||
},
|
||||
independent = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "troubleshooting",
|
||||
name = stringResource(R.string.troubleshooting),
|
||||
navController = navController,
|
||||
independent = true,
|
||||
description = stringResource(R.string.troubleshooting_description)
|
||||
)
|
||||
|
||||
// LaunchedEffect(Unit) {
|
||||
// actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable()
|
||||
// }
|
||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
||||
|
||||
/*StyledToggle(
|
||||
label = stringResource(R.string.act_as_an_apple_device),
|
||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||
checkedState = actAsAppleDevice,
|
||||
onCheckedChange = {
|
||||
actAsAppleDevice.value = it
|
||||
isProcessingSdp.value = true
|
||||
coroutineScope.launch {
|
||||
if (it) {
|
||||
val radareOffsetFinder = RadareOffsetFinder(context)
|
||||
val success = radareOffsetFinder.findSdpOffset()
|
||||
if (success) {
|
||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RadareOffsetFinder.clearSdpOffset()
|
||||
}
|
||||
isProcessingSdp.value = false
|
||||
}
|
||||
},
|
||||
independent = true,
|
||||
enabled = !isProcessingSdp.value
|
||||
)*/
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { showResetDialog.value = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Reset",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reset_hook_offset),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "open_source_licenses",
|
||||
name = stringResource(R.string.open_source_licenses),
|
||||
navController = navController,
|
||||
independent = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// if (showResetDialog.value) {
|
||||
// AlertDialog(
|
||||
// onDismissRequest = { showResetDialog.value = false },
|
||||
// title = {
|
||||
// Text(
|
||||
// "Reset Hook Offset",
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Medium
|
||||
// )
|
||||
// },
|
||||
// text = {
|
||||
// Text(
|
||||
// stringResource(R.string.reset_hook_offset_description),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
// )
|
||||
// },
|
||||
// confirmButton = {
|
||||
// val successText = stringResource(R.string.hook_offset_reset_success)
|
||||
// val failureText = stringResource(R.string.hook_offset_reset_failure)
|
||||
// TextButton(
|
||||
// onClick = {
|
||||
// if (RadareOffsetFinder.clearHookOffsets()) {
|
||||
// Toast.makeText(
|
||||
// context,
|
||||
// successText,
|
||||
// Toast.LENGTH_LONG
|
||||
// ).show()
|
||||
//
|
||||
// navController.navigate("onboarding") {
|
||||
// popUpTo("settings") { inclusive = true }
|
||||
// }
|
||||
// } else {
|
||||
// Toast.makeText(
|
||||
// context,
|
||||
// failureText,
|
||||
// Toast.LENGTH_SHORT
|
||||
// ).show()
|
||||
// }
|
||||
// showResetDialog.value = false
|
||||
// },
|
||||
// colors = ButtonDefaults.textButtonColors(
|
||||
// contentColor = MaterialTheme.colorScheme.error
|
||||
// )
|
||||
// ) {
|
||||
// Text(
|
||||
// stringResource(R.string.reset),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Medium
|
||||
// )
|
||||
// }
|
||||
// },
|
||||
// dismissButton = {
|
||||
// TextButton(
|
||||
// onClick = { showResetDialog.value = false }
|
||||
// ) {
|
||||
// Text(
|
||||
// "Cancel",
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
// fontWeight = FontWeight.Medium
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
|
||||
if (showIrkDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showIrkDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.set_identity_resolving_key),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_irk_hex),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = irkValue.value,
|
||||
onValueChange = {
|
||||
irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||
irkError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = irkError.value != 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 (irkError.value != null) {
|
||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.irk_hex_value)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.irk_set_success)
|
||||
val errorText = stringResource(R.string.error_converting_hex)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!validateHexInput(irkValue.value)) {
|
||||
irkError.value = "Must be exactly 32 hex characters"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
try {
|
||||
val hexBytes = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = irkValue.value.substring(i * 2, i * 2 + 2)
|
||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
|
||||
val base64Value = Base64.encode(hexBytes)
|
||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
|
||||
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showIrkDialog.value = false
|
||||
} catch (e: Exception) {
|
||||
irkError.value = errorText + " " + (e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showIrkDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showEncKeyDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEncKeyDialog.value = false },
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.set_encryption_key),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_enc_key_hex),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = encKeyValue.value,
|
||||
onValueChange = {
|
||||
encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
||||
encKeyError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = encKeyError.value != 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 (encKeyError.value != null) {
|
||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.enc_key_hex_value)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
val successText = stringResource(R.string.encryption_key_set_success)
|
||||
val errorText = stringResource(R.string.error_converting_hex)
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!validateHexInput(encKeyValue.value)) {
|
||||
encKeyError.value = "Must be exactly 32 hex characters"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
try {
|
||||
val hexBytes = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2)
|
||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
|
||||
val base64Value = Base64.encode(hexBytes)
|
||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
|
||||
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showEncKeyDialog.value = false
|
||||
} catch (e: Exception) {
|
||||
encKeyError.value = errorText + " " + (e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showEncKeyDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showCameraDialog.value) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCameraDialog.value = 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 = cameraPackageValue.value,
|
||||
onValueChange = {
|
||||
cameraPackageValue.value = it
|
||||
cameraPackageError.value = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cameraPackageError.value != 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 (cameraPackageError.value != null) {
|
||||
Text(cameraPackageError.value!!, 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 = {
|
||||
if (cameraPackageValue.value.isBlank()) {
|
||||
sharedPreferences.edit { remove("custom_camera_package") }
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showCameraDialog.value = false
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) }
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
showCameraDialog.value = false
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { showCameraDialog.value = false }
|
||||
) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.core.content.edit
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.SelectItem
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.services.AppListenerService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun CameraControlScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val service = ServiceManager.getService()!!
|
||||
var currentCameraAction by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
val cameraOptions = listOf(
|
||||
SelectItem(
|
||||
name = stringResource(R.string.off),
|
||||
selected = currentCameraAction == null,
|
||||
onClick = {
|
||||
sharedPreferences.edit { remove("camera_action") }
|
||||
currentCameraAction = null
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.press_once),
|
||||
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||
onClick = {
|
||||
if (!isAppListenerServiceEnabled(context)) {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
} else {
|
||||
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
|
||||
currentCameraAction = StemPressType.SINGLE_PRESS
|
||||
}
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.press_and_hold_airpods),
|
||||
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||
onClick = {
|
||||
if (!isAppListenerServiceEnabled(context)) {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
} else {
|
||||
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
|
||||
currentCameraAction = StemPressType.LONG_PRESS
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.camera_control)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledSelectList(items = cameraOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.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.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class PacketInfo(
|
||||
val type: String,
|
||||
val description: String,
|
||||
val rawData: String,
|
||||
val parsedData: Map<String, String> = emptyMap(),
|
||||
val isUnknown: Boolean = false
|
||||
)
|
||||
|
||||
fun parsePacket(message: String): PacketInfo {
|
||||
val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9)
|
||||
val bytes = rawData.split(" ").mapNotNull {
|
||||
it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte()
|
||||
}.toByteArray()
|
||||
|
||||
val airPodsService = ServiceManager.getService()
|
||||
if (airPodsService != null) {
|
||||
return when {
|
||||
message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData)
|
||||
airPodsService.batteryNotification.isBatteryData(bytes) -> {
|
||||
val batteryInfo = mutableMapOf<String, String>()
|
||||
airPodsService.batteryNotification.setBattery(bytes)
|
||||
val batteries = airPodsService.batteryNotification.getBattery()
|
||||
val batteryInfoString = batteries.joinToString(", ") { battery ->
|
||||
"${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
batteries.forEach { battery ->
|
||||
if (battery.status != BatteryStatus.DISCONNECTED) {
|
||||
batteryInfo[battery.getComponentName() ?: "Unknown"] =
|
||||
"${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
|
||||
}
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Battery",
|
||||
batteryInfoString,
|
||||
rawData,
|
||||
batteryInfo
|
||||
)
|
||||
}
|
||||
airPodsService.ancNotification.isANCData(bytes) -> {
|
||||
airPodsService.ancNotification.setStatus(bytes)
|
||||
val mode = when (airPodsService.ancNotification.status) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
|
||||
PacketInfo(
|
||||
"Noise Control",
|
||||
"Mode: $mode",
|
||||
rawData,
|
||||
mapOf("Mode" to mode)
|
||||
)
|
||||
}
|
||||
airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> {
|
||||
airPodsService.earDetectionNotification.setStatus(bytes)
|
||||
val status = airPodsService.earDetectionNotification.status
|
||||
val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear"
|
||||
val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear"
|
||||
|
||||
PacketInfo(
|
||||
"Ear Detection",
|
||||
"Primary: $primaryStatus, Secondary: $secondaryStatus",
|
||||
rawData,
|
||||
mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus)
|
||||
)
|
||||
}
|
||||
airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> {
|
||||
airPodsService.conversationAwarenessNotification.setData(bytes)
|
||||
val statusMap = mapOf(
|
||||
1.toByte() to "Started speaking",
|
||||
2.toByte() to "Speaking",
|
||||
8.toByte() to "Stopped speaking",
|
||||
9.toByte() to "Not speaking"
|
||||
)
|
||||
val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?:
|
||||
"Unknown (${airPodsService.conversationAwarenessNotification.status})"
|
||||
|
||||
PacketInfo(
|
||||
"Conversation Awareness",
|
||||
"Status: $status",
|
||||
rawData,
|
||||
mapOf("Status" to status)
|
||||
)
|
||||
}
|
||||
isHeadTrackingData(bytes) -> {
|
||||
val horizontal = if (bytes.size >= 53)
|
||||
"${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown"
|
||||
val vertical = if (bytes.size >= 55)
|
||||
"${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown"
|
||||
|
||||
PacketInfo(
|
||||
"Head Tracking",
|
||||
"Position data",
|
||||
rawData,
|
||||
mapOf("Horizontal" to horizontal, "Vertical" to vertical)
|
||||
)
|
||||
}
|
||||
else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
} else {
|
||||
return if (message.startsWith("Sent")) {
|
||||
parseOutgoingPacket(bytes, rawData)
|
||||
} else {
|
||||
PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
|
||||
if (bytes.size < 7) {
|
||||
return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
|
||||
return when {
|
||||
bytes.size >= 16 &&
|
||||
bytes[0] == 0x00.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() -> {
|
||||
PacketInfo("Handshake", "Initial handshake with AirPods", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x0d.toByte() -> {
|
||||
val mode = when (bytes[7].toInt()) {
|
||||
1 -> "Off"
|
||||
2 -> "Noise Cancellation"
|
||||
3 -> "Transparency"
|
||||
4 -> "Adaptive"
|
||||
else -> "Unknown"
|
||||
}
|
||||
PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x28.toByte() -> {
|
||||
val mode = if (bytes[7].toInt() == 1) "On" else "Off"
|
||||
PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode))
|
||||
}
|
||||
|
||||
bytes.size > 10 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x17.toByte() -> {
|
||||
val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop"
|
||||
PacketInfo("Head Tracking", "$action head tracking", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 11 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x09.toByte() &&
|
||||
bytes[5] == 0x00.toByte() &&
|
||||
bytes[6] == 0x1A.toByte() -> {
|
||||
PacketInfo("Long Press Config", "Change long press modes", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x4d.toByte() -> {
|
||||
PacketInfo("Feature Request", "Set specific features", rawData)
|
||||
}
|
||||
|
||||
bytes.size >= 9 &&
|
||||
bytes[0] == 0x04.toByte() &&
|
||||
bytes[1] == 0x00.toByte() &&
|
||||
bytes[2] == 0x04.toByte() &&
|
||||
bytes[3] == 0x00.toByte() &&
|
||||
bytes[4] == 0x0f.toByte() -> {
|
||||
PacketInfo("Notifications", "Request notifications", rawData)
|
||||
}
|
||||
|
||||
else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun DebugScreen(navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val airPodsService = remember { ServiceManager.getService() }
|
||||
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
|
||||
|
||||
val refreshTrigger = remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(refreshTrigger.intValue) {
|
||||
while(true) {
|
||||
delay(1000)
|
||||
refreshTrigger.intValue += 1
|
||||
}
|
||||
}
|
||||
|
||||
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
|
||||
|
||||
fun copyToClipboard(text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = ClipData.newPlainText("Packet Data", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
LaunchedEffect(packetLogs.size, refreshTrigger.intValue) {
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(packetLogs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = "Debug",
|
||||
actionButtons = listOf(
|
||||
{scaffoldBackdrop ->
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
airPodsService?.clearLogs()
|
||||
expandedItems.value = emptySet()
|
||||
},
|
||||
icon = "",
|
||||
darkMode = isDarkTheme,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.navigationBarsPadding()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = {
|
||||
items(packetLogs.size) { index ->
|
||||
val message = packetLogs.elementAt(index)
|
||||
val isSent = message.startsWith("Sent")
|
||||
val isExpanded = expandedItems.value.contains(index)
|
||||
val packetInfo = parsePacket(message)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
expandedItems.value = if (isExpanded) {
|
||||
expandedItems.value - index
|
||||
} else {
|
||||
expandedItems.value + index
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
copyToClipboard(packetInfo.rawData)
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (isSent) "" else "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isSent) Color(0xFF4CD964) else Color(0xFFFF3B30)
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = if (packetInfo.isUnknown) {
|
||||
val shortenedData = packetInfo.rawData.take(60) +
|
||||
(if (packetInfo.rawData.length > 60) "..." else "")
|
||||
shortenedData
|
||||
} else {
|
||||
"${packetInfo.type}: ${packetInfo.description}"
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
)
|
||||
)
|
||||
if (isExpanded) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (packetInfo.parsedData.isNotEmpty()) {
|
||||
packetInfo.parsedData.forEach { (key, value) ->
|
||||
Row {
|
||||
Text(
|
||||
text = "$key: ",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Raw: ${packetInfo.rawData}",
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily(Font(R.font.hack))
|
||||
),
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val packet = remember { mutableStateOf(TextFieldValue("")) }
|
||||
TextField(
|
||||
value = packet.value,
|
||||
onValueChange = { packet.value = it },
|
||||
label = { Text("Packet") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(bottom = 5.dp),
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (packet.value.text.isNotBlank()) {
|
||||
airPodsService?.value?.aacpManager?.sendPacket(
|
||||
packet.value.text
|
||||
.split(" ")
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
)
|
||||
packet.value = TextFieldValue("")
|
||||
focusManager.clearFocus()
|
||||
|
||||
if (packetLogs.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0),
|
||||
scrollOffset = 0
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
listState.scrollToItem(
|
||||
index = (packetLogs.size - 1).coerceAtLeast(0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
Icon(Icons.Filled.Send, contentDescription = "Send")
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
|
||||
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
|
||||
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,749 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
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.rememberTextMeasurer
|
||||
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 com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledButton
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.HeadTracking
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen() {
|
||||
DisposableEffect(Unit) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
onDispose {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.head_tracking),
|
||||
actionButtons = listOf(
|
||||
{ scaffoldBackdrop ->
|
||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
||||
StyledIconButton(
|
||||
onClick = {
|
||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
||||
ServiceManager.getService()?.startHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||
} else {
|
||||
ServiceManager.getService()?.stopHeadTracking()
|
||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||
}
|
||||
},
|
||||
icon = if (isActive) "" else "",
|
||||
darkMode = isDarkTheme,
|
||||
backdrop = scaffoldBackdrop
|
||||
)
|
||||
}
|
||||
),
|
||||
) { spacerHeight, hazeState ->
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.hazeSource(state = hazeState)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
StyledToggle(
|
||||
label = "Head Gestures",
|
||||
sharedPreferences = sharedPreferences,
|
||||
sharedPreferenceKey = "head_gestures",
|
||||
)
|
||||
|
||||
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))
|
||||
Text(
|
||||
"Head Orientation",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
HeadVisualization()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Velocity",
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
AccelerationPlot()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||
StyledButton(
|
||||
onClick = {
|
||||
gestureText = gestureTextValue
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier.fillMaxWidth(0.75f),
|
||||
maxScale = 0.05f
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
ParticleText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
onAnimationComplete = {
|
||||
shouldExplode = false
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private data class Particle(
|
||||
val initialPosition: Offset,
|
||||
val velocity: Offset,
|
||||
var alpha: Float = 1f
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ParticleText(
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
onAnimationComplete: () -> Unit,
|
||||
) {
|
||||
val particles = remember { mutableStateListOf<Particle>() }
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
var isAnimating by remember { mutableStateOf(true) }
|
||||
var textVisible by remember { mutableStateOf(true) }
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxWidth()) {
|
||||
val textLayoutResult = textMeasurer.measure(text, style)
|
||||
val textBounds = textLayoutResult.size
|
||||
val centerX = (size.width - textBounds.width) / 2
|
||||
val centerY = size.height / 2
|
||||
|
||||
if (textVisible && particles.isEmpty()) {
|
||||
drawText(
|
||||
textMeasurer = textMeasurer,
|
||||
text = text,
|
||||
style = style,
|
||||
topLeft = Offset(centerX, centerY - textBounds.height / 2)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.isEmpty()) {
|
||||
val random = Random(System.currentTimeMillis())
|
||||
for (@Suppress("Unused")i in 0..100) {
|
||||
val x = centerX + random.nextFloat() * textBounds.width
|
||||
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
|
||||
val vx = (random.nextFloat() - 0.5f) * 20
|
||||
val vy = (random.nextFloat() - 0.5f) * 20
|
||||
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
|
||||
}
|
||||
}
|
||||
|
||||
particles.forEach { particle ->
|
||||
drawCircle(
|
||||
color = style.color.copy(alpha = particle.alpha),
|
||||
radius = 0.5.dp.toPx(),
|
||||
center = particle.initialPosition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(text) {
|
||||
while (isAnimating) {
|
||||
delay(16)
|
||||
particles.forEachIndexed { index, particle ->
|
||||
particles[index] = particle.copy(
|
||||
initialPosition = particle.initialPosition + particle.velocity,
|
||||
alpha = (particle.alpha - 0.02f).coerceAtLeast(0f)
|
||||
)
|
||||
}
|
||||
|
||||
if (particles.all { it.alpha <= 0f }) {
|
||||
isAnimating = false
|
||||
onAnimationComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadVisualization() {
|
||||
val orientation by HeadTracking.orientation.collectAsState()
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val strokeColor = if (darkTheme) Color.White else Color.Black
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = backgroundColor
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val center = Offset(width / 2, height / 2)
|
||||
val faceRadius = height * 0.35f
|
||||
|
||||
val pitch = Math.toRadians(orientation.pitch.toDouble())
|
||||
val yaw = Math.toRadians(orientation.yaw.toDouble())
|
||||
|
||||
val cosY = cos(yaw).toFloat()
|
||||
val sinY = sin(yaw).toFloat()
|
||||
val cosP = cos(pitch).toFloat()
|
||||
val sinP = sin(pitch).toFloat()
|
||||
|
||||
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val x1 = x * cosY - z * sinY
|
||||
val z1 = x * sinY + z * cosY
|
||||
|
||||
val y2 = y * cosP - z1 * sinP
|
||||
val z2 = y * sinP + z1 * cosP
|
||||
|
||||
return Triple(x1, y2, z2)
|
||||
}
|
||||
|
||||
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
|
||||
val (x, y, z) = point
|
||||
val scale = 1f + (z / width)
|
||||
return Pair(center.x + x * scale, center.y + y * scale)
|
||||
}
|
||||
|
||||
val earWidth = height * 0.08f
|
||||
val earHeight = height * 0.2f
|
||||
val earOffsetX = height * 0.4f
|
||||
val earOffsetY = 0f
|
||||
val earZ = 0f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
|
||||
val (earX, earY) = project(rotated)
|
||||
drawRoundRect(
|
||||
color = strokeColor,
|
||||
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
|
||||
size = Size(earWidth, earHeight),
|
||||
cornerRadius = CornerRadius(earWidth/2),
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
}
|
||||
|
||||
val spherePath = Path()
|
||||
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
|
||||
spherePath.moveTo(firstPoint.first, firstPoint.second)
|
||||
|
||||
for (i in 1..32) {
|
||||
val angle = (i * 2 * Math.PI / 32).toFloat()
|
||||
val point = project(rotate3D(Triple(
|
||||
cos(angle) * faceRadius,
|
||||
sin(angle) * faceRadius,
|
||||
0f
|
||||
)))
|
||||
spherePath.lineTo(point.first, point.second)
|
||||
}
|
||||
spherePath.close()
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.3f,
|
||||
center.y - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 1.4f,
|
||||
intArrayOf(
|
||||
backgroundColor.copy(alpha = 1f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.95f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.9f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.8f).toArgb(),
|
||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||
),
|
||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), paint)
|
||||
|
||||
val highlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 0.9f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.argb(100, 255, 255, 255),
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 0.3f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 30 else 60
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||
|
||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||
faceRadius * 0.7f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.WHITE,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
),
|
||||
floatArrayOf(0f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 15 else 30
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||
|
||||
val shadowPaint = android.graphics.Paint().apply {
|
||||
style = android.graphics.Paint.Style.FILL
|
||||
shader = android.graphics.RadialGradient(
|
||||
center.x + sinY * faceRadius * 0.5f,
|
||||
center.y - sinP * faceRadius * 0.5f,
|
||||
faceRadius * 1.1f,
|
||||
intArrayOf(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.BLACK
|
||||
),
|
||||
floatArrayOf(0.7f, 1f),
|
||||
android.graphics.Shader.TileMode.CLAMP
|
||||
)
|
||||
alpha = if (darkTheme) 40 else 20
|
||||
}
|
||||
drawPath(spherePath.asAndroidPath(), shadowPaint)
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = spherePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(width = 4.dp.toPx())
|
||||
)
|
||||
|
||||
val smileRadius = faceRadius * 0.5f
|
||||
val smileStartAngle = -340f
|
||||
val smileSweepAngle = 140f
|
||||
val smileOffsetY = faceRadius * 0.1f
|
||||
|
||||
val smilePath = Path()
|
||||
for (i in 0..32) {
|
||||
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
|
||||
val x = cos(angle.toFloat()) * smileRadius
|
||||
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
|
||||
|
||||
val rotated = rotate3D(Triple(x, y, 0f))
|
||||
val projected = project(rotated)
|
||||
|
||||
if (i == 0) {
|
||||
smilePath.moveTo(projected.first, projected.second)
|
||||
} else {
|
||||
smilePath.lineTo(projected.first, projected.second)
|
||||
}
|
||||
}
|
||||
|
||||
drawPath(
|
||||
path = smilePath,
|
||||
color = strokeColor,
|
||||
style = Stroke(
|
||||
width = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
)
|
||||
|
||||
val eyeOffsetX = height * 0.15f
|
||||
val eyeOffsetY = height * 0.1f
|
||||
val eyeLength = height * 0.08f
|
||||
|
||||
for (xSign in listOf(-1f, 1f)) {
|
||||
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
|
||||
val (eyeX, eyeY) = project(rotated)
|
||||
drawLine(
|
||||
color = strokeColor,
|
||||
start = Offset(eyeX, eyeY - eyeLength/2),
|
||||
end = Offset(eyeX, eyeY + eyeLength/2),
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
typeface = android.graphics.Typeface.create(
|
||||
"SF Pro",
|
||||
android.graphics.Typeface.NORMAL
|
||||
)
|
||||
}
|
||||
|
||||
val pitch = orientation.pitch.toInt()
|
||||
val yaw = orientation.yaw.toInt()
|
||||
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
|
||||
|
||||
drawText(
|
||||
text,
|
||||
width - 8.dp.toPx(),
|
||||
height - 8.dp.toPx(),
|
||||
paint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccelerationPlot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
var maxAbs by remember { mutableFloatStateOf(1000f) }
|
||||
|
||||
LaunchedEffect(acceleration) {
|
||||
points.add(Pair(acceleration.horizontal, acceleration.vertical))
|
||||
if (points.size > maxPoints) {
|
||||
points.removeAt(0)
|
||||
}
|
||||
|
||||
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
|
||||
maxAbs = maxOf(currentMax * 1.2f, 1000f)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xScale = width / maxPoints
|
||||
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
|
||||
val zeroY = height / 2
|
||||
|
||||
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
|
||||
|
||||
for (i in 0..maxPoints step 10) {
|
||||
val x = i * xScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
val gridStep = maxAbs / 4
|
||||
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
|
||||
val y = zeroY - value * yScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(width, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
drawLine(
|
||||
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
|
||||
start = Offset(0f, zeroY),
|
||||
end = Offset(width, zeroY),
|
||||
strokeWidth = 1.5f.dp.toPx()
|
||||
)
|
||||
|
||||
if (points.size > 1) {
|
||||
for (i in 0 until points.size - 1) {
|
||||
val x1 = i * xScale
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFF007AFF),
|
||||
start = Offset(x1, zeroY - points[i].first * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].first * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = Color(0xFFFF3B30),
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.RIGHT
|
||||
}
|
||||
|
||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||
drawText("0", 30.dp.toPx(), height/2, paint)
|
||||
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
|
||||
}
|
||||
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = android.graphics.Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = android.graphics.Paint.Align.LEFT
|
||||
}
|
||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Preview
|
||||
@Composable
|
||||
fun HeadTrackingScreenPreview() {
|
||||
HeadTrackingScreen()
|
||||
}
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
||||
isSystemInDarkTheme()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val hazeState = remember { HazeState() }
|
||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.adjustments)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val 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 {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||
balanceSliderValue.floatValue = parsed.balance
|
||||
toneSliderValue.floatValue = parsed.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(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) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = amplificationSliderValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
)
|
||||
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = balanceSliderValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
mutableFloatState = toneSliderValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checkedState = conversationBoostEnabled,
|
||||
independent = true,
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||
|
||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val initialLoad = remember { mutableStateOf(true) }
|
||||
|
||||
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 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
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_aid),
|
||||
snackbarHostState = snackbarHostState,
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
hazeStateS.value = hazeState
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
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 adjustMediaEnabled = 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) {
|
||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||
showDialog.value = true
|
||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
|
||||
hearingAidEnabled.value = false
|
||||
}
|
||||
initialLoad.value = false
|
||||
}
|
||||
|
||||
// fun onAdjustPhoneChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
// fun onAdjustMediaChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid),
|
||||
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)
|
||||
)
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.clip(
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checkedState = hearingAidEnabled,
|
||||
independent = false
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
NavigationButton(
|
||||
to = "hearing_aid_adjustments",
|
||||
name = stringResource(R.string.adjustments),
|
||||
navController,
|
||||
independent = false
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "update_hearing_test",
|
||||
name = stringResource(R.string.update_hearing_test),
|
||||
navController,
|
||||
independent = true
|
||||
)
|
||||
|
||||
// not implemented yet
|
||||
|
||||
// StyledToggle(
|
||||
// title = stringResource(R.string.media_assist),
|
||||
// label = stringResource(R.string.media_assist),
|
||||
// checkedState = mediaAssistEnabled,
|
||||
// independent = true,
|
||||
// description = stringResource(R.string.media_assist_description)
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Column (
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// ) {
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_media),
|
||||
// checkedState = adjustMediaEnabled,
|
||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 12.dp)
|
||||
// )
|
||||
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_calls),
|
||||
// checkedState = adjustPhoneEnabled,
|
||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = "Enable Hearing Aid",
|
||||
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
|
||||
confirmText = "Enable",
|
||||
dismissText = "Cancel",
|
||||
onConfirm = {
|
||||
showDialog.value = false
|
||||
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
|
||||
if (!enrolled) {
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
||||
} else {
|
||||
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
||||
}
|
||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
|
||||
hearingAidEnabled.value = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
||||
val parsed = parseTransparencySettingsResponse(data)
|
||||
val disabledSettings = parsed.copy(enabled = false)
|
||||
sendTransparencySettings(attManager, disabledSettings)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
hazeState = hazeStateS.value,
|
||||
// backdrop = backdrop
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
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.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledToggle
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingProtectionScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val service = ServiceManager.getService()
|
||||
if (service == null) return
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_protection),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
||||
)
|
||||
|
||||
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),
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSlider
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun OpenSourceLicensesScreen(navController: NavController) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.open_source_licenses)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val libraries by produceLibraries {
|
||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.SelectItem
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.experimental.and
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun RightDivider() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 72.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RightDividerNoIcon() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 20.dp, end = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(navController: NavController, name: String) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
|
||||
if (modesByte != null) {
|
||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
|
||||
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = name
|
||||
) { spacerHeight ->
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
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 = {
|
||||
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.digital_assistant),
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||
}
|
||||
)
|
||||
)
|
||||
StyledSelectList(items = actionItems)
|
||||
|
||||
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)),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
|
||||
val allowOff = offListeningModeValue == 1.toByte()
|
||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||
|
||||
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||
var currentByte by remember { mutableStateOf(initialByte) }
|
||||
|
||||
val listeningModeItems = mutableListOf<SelectItem>()
|
||||
if (allowOff) {
|
||||
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 = {
|
||||
val bit = 0x01
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
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 = {
|
||||
val bit = 0x04
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
SelectItem(
|
||||
name = stringResource(R.string.adaptive),
|
||||
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||
iconRes = R.drawable.adaptive,
|
||||
selected = (currentByte and 0x08) != 0,
|
||||
onClick = {
|
||||
val bit = 0x08
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
),
|
||||
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 = {
|
||||
val bit = 0x02
|
||||
val newValue = if ((currentByte and bit) != 0) {
|
||||
val temp = currentByte and bit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or bit
|
||||
}
|
||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
||||
newValue.toByte()
|
||||
)
|
||||
sharedPreferences.edit {
|
||||
putInt("long_press_byte", newValue)
|
||||
}
|
||||
currentByte = newValue
|
||||
}
|
||||
)
|
||||
))
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
||||
}
|
||||
|
||||
fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
return count
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun RenameScreen(navController: NavController) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
name.value = name.value.copy(selection = TextRange(name.value.text.length))
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.name),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val cursorColor = if (isDarkTheme) Color.White else Color.Black
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(58.dp)
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = {
|
||||
name.value = it
|
||||
sharedPreferences.edit {putString("name", it.text)}
|
||||
ServiceManager.getService()?.setName(it.text)
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(cursorColor),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
innerTextField()
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
name.value = TextFieldValue("")
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenameScreenPreview() {
|
||||
RenameScreen(navController = NavController(LocalContext.current))
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.utils.LogCollector
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CustomIconButton(
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun TroubleshootingScreen(navController: NavController) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val logCollector = remember { LogCollector(context) }
|
||||
val savedLogs = remember { mutableStateListOf<File>() }
|
||||
|
||||
var isCollectingLogs by remember { mutableStateOf(false) }
|
||||
var showTroubleshootingSteps by remember { mutableStateOf(false) }
|
||||
var currentStep by remember { mutableIntStateOf(0) }
|
||||
var logContent by remember { mutableStateOf("") }
|
||||
var selectedLogFile by remember { mutableStateOf<File?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteAllDialog by remember { mutableStateOf(false) }
|
||||
var isLoadingLogContent by remember { mutableStateOf(false) }
|
||||
var logContentLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isCollectingLogs) {
|
||||
while (isCollectingLogs) {
|
||||
delay(250)
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
|
||||
|
||||
var instructionText by remember { mutableStateOf("") }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val logsDir = File(context.filesDir, "logs")
|
||||
if (logsDir.exists()) {
|
||||
savedLogs.clear()
|
||||
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
|
||||
?.sortedByDescending { it.lastModified() } ?: emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(logContent.toByteArray())
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to save log: ${e.localizedMessage}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentStep) {
|
||||
instructionText = when (currentStep) {
|
||||
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
|
||||
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
|
||||
2 -> "Preparing to collect logs... Please wait."
|
||||
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
|
||||
4 -> "Log collection complete! You can now save or share the logs."
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun openLogBottomSheet(file: File) {
|
||||
selectedLogFile = file
|
||||
logContent = ""
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = false
|
||||
showBottomSheet = true
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.troubleshooting)
|
||||
){ spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.hazeSource(state = hazeState)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.saved_logs),
|
||||
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 = 4.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
if (savedLogs.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_logs_found),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Total Logs: ${savedLogs.size}",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (savedLogs.size > 1) {
|
||||
TextButton(
|
||||
onClick = { showDeleteAllDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete All")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savedLogs.forEach { logFile ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable {
|
||||
openLogBottomSheet(logFile)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = logFile.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(logFile.lastModified())),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
onClick = {
|
||||
selectedLogFile = logFile
|
||||
showDeleteDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(300))
|
||||
) {
|
||||
Button(
|
||||
onClick = { showTroubleshootingSteps = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
enabled = !isCollectingLogs
|
||||
) {
|
||||
Text(stringResource(R.string.collect_logs))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)) +
|
||||
slideInVertically(animationSpec = tween(300)) { it / 2 },
|
||||
exit = fadeOut(animationSpec = tween(300)) +
|
||||
slideOutVertically(animationSpec = tween(300)) { it / 2 }
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting_steps),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val textAlpha = animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "textAlpha"
|
||||
)
|
||||
|
||||
Text(
|
||||
text = instructionText,
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = textAlpha.value),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (currentStep) {
|
||||
0 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.openXposedSettings(context)
|
||||
delay(2000)
|
||||
currentStep = 1
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Open Xposed Settings")
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 2
|
||||
isCollectingLogs = true
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
logCollector.clearLogs()
|
||||
|
||||
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
|
||||
|
||||
logCollector.killBluetoothService()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(500)
|
||||
currentStep = 3
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.US
|
||||
).format(Date())
|
||||
|
||||
logContent =
|
||||
logCollector.startLogCollection(
|
||||
listener = { /* Removed live log display */ },
|
||||
connectionDetectedCallback = {
|
||||
launch {
|
||||
delay(5000)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isCollectingLogs) {
|
||||
logCollector.stopLogCollection()
|
||||
currentStep = 4
|
||||
isCollectingLogs =
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val logFile =
|
||||
logCollector.saveLogToInternalStorage(
|
||||
"airpods_log_$timestamp.txt",
|
||||
logContent
|
||||
)
|
||||
logFile?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
savedLogs.add(0, it)
|
||||
selectedLogFile = it
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log saved: ${it.name}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Error collecting logs: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
isCollectingLogs = false
|
||||
currentStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
|
||||
2, 3 -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
|
||||
fontSize = 14.sp,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (currentStep == 3) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.addLogMarker(
|
||||
LogCollector.LogMarkerType.CUSTOM,
|
||||
"Manual stop requested by user"
|
||||
)
|
||||
delay(1000)
|
||||
logCollector.stopLogCollection()
|
||||
delay(500)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
currentStep = 4
|
||||
isCollectingLogs = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log collection stopped",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text("Stop Collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4 -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
fileUri
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(
|
||||
file.absolutePath
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 0
|
||||
showTroubleshootingSteps = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && selectedLogFile != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Log File") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete this log file? This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
if (file.delete()) {
|
||||
savedLogs.remove(file)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log file deleted",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log file",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteAllDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteAllDialog = false },
|
||||
title = { Text("Delete All Logs") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
var deletedCount = 0
|
||||
savedLogs.forEach { file ->
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (deletedCount > 0) {
|
||||
savedLogs.clear()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Deleted $deletedCount log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteAllDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete All", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteAllDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
LaunchedEffect(selectedLogFile) {
|
||||
if (!logContentLoaded) {
|
||||
delay(300)
|
||||
withContext(Dispatchers.IO) {
|
||||
isLoadingLogContent = true
|
||||
logContent = try {
|
||||
selectedLogFile?.readText() ?: ""
|
||||
} catch (e: Exception) {
|
||||
"Error loading log content: ${e.message}"
|
||||
}
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = selectedLogFile?.name ?: "Log Content",
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(selectedLogFile?.lastModified() ?: 0)),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingLogContent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = accentColor)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(
|
||||
color = Color.Black,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
val horizontalScrollState = rememberScrollState()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.horizontalScroll(horizontalScrollState)
|
||||
.verticalScroll(verticalScrollState)
|
||||
) {
|
||||
Text(
|
||||
text = logContent,
|
||||
fontSize = 14.sp,
|
||||
color = Color.LightGray,
|
||||
lineHeight = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(file.absolutePath)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
logCollector.stopLogCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.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.input.KeyboardType
|
||||
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.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.ATTHandles
|
||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
||||
import java.io.IOException
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val attManager = ServiceManager.getService()?.attManager
|
||||
if (attManager == null) {
|
||||
Text(
|
||||
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.hearing_test)
|
||||
) { spacerHeight, hazeState ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_test_value_instruction),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
||||
|
||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.value,
|
||||
rightAmplification = rightAmplification.value,
|
||||
leftTone = tone.value,
|
||||
rightTone = tone.value,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val hearingAidATTListener = remember {
|
||||
object : (ByteArray) -> Unit {
|
||||
override fun invoke(value: ByteArray) {
|
||||
val parsed = parseHearingAidSettingsResponse(value)
|
||||
if (parsed != null) {
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
tone.value = parsed.leftTone
|
||||
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.value = parsed.ownVoiceAmplification
|
||||
leftAmplification.value = parsed.leftAmplification
|
||||
rightAmplification.value = parsed.rightAmplification
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
|
||||
if (!initialLoadComplete.value) {
|
||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!initialReadSucceeded.value) {
|
||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.value,
|
||||
rightAmplification = rightAmplification.value,
|
||||
leftTone = tone.value,
|
||||
rightTone = tone.value,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.value
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Connecting to ATT...")
|
||||
try {
|
||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
||||
|
||||
var parsedSettings: HearingAidSettings? = null
|
||||
for (attempt in 1..3) {
|
||||
initialReadAttempts.intValue = attempt
|
||||
try {
|
||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||
break
|
||||
} else {
|
||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
||||
}
|
||||
delay(200)
|
||||
}
|
||||
|
||||
if (parsedSettings != null) {
|
||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
tone.value = parsedSettings.leftTone
|
||||
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
|
||||
leftAmplification.value = parsedSettings.leftAmplification
|
||||
rightAmplification.value = parsedSettings.rightAmplification
|
||||
initialReadSucceeded.value = true
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
initialLoadComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(60.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
frequencies.forEachIndexed { index, freq ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = freq,
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
textAlign = TextAlign.End,
|
||||
style = TextStyle(
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = leftEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = leftEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
leftEQ.value = newArray
|
||||
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rightEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = rightEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
rightEQ.value = newArray
|
||||
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import android.annotation.SuppressLint
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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 com.kyant.backdrop.backdrops.layerBackdrop
|
||||
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.composables.StyledScaffold
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun VersionScreen(navController: NavController) {
|
||||
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 textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.customize_adaptive_audio)
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
){
|
||||
Text(
|
||||
text = stringResource(R.string.version),
|
||||
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)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.version) + " 1",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version1 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(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) + " 2",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version2 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(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) + " 3",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = airpodsInstance.version3 ?: "N/A",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LibrePodsTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
|
||||
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
|
||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
enum class ATTHandles(val value: Int) {
|
||||
TRANSPARENCY(0x18),
|
||||
LOUD_SOUND_REDUCTION(0x1B),
|
||||
HEARING_AID(0x2A),
|
||||
}
|
||||
|
||||
enum class ATTCCCDHandles(val value: Int) {
|
||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||
}
|
||||
|
||||
class ATTManager(private val device: BluetoothDevice) {
|
||||
companion object {
|
||||
private const val TAG = "ATTManager"
|
||||
|
||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
||||
}
|
||||
|
||||
var socket: BluetoothSocket? = null
|
||||
private var input: InputStream? = null
|
||||
private var output: OutputStream? = null
|
||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||
private var notificationJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
// queue for non-notification PDUs (responses to requests)
|
||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connect() {
|
||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
socket = createBluetoothSocket(device, uuid)
|
||||
socket!!.connect()
|
||||
input = socket!!.inputStream
|
||||
output = socket!!.outputStream
|
||||
Log.d(TAG, "Connected to ATT")
|
||||
|
||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (socket?.isConnected == true) {
|
||||
try {
|
||||
val pdu = readPDU()
|
||||
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
|
||||
// notification -> dispatch to listeners
|
||||
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
|
||||
val value = pdu.copyOfRange(3, pdu.size)
|
||||
listeners[handle]?.forEach { listener ->
|
||||
try {
|
||||
listener(value)
|
||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not a notification -> treat as a response for pending request(s)
|
||||
responses.put(pdu)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
||||
if (socket?.isConnected != true) break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
try {
|
||||
notificationJob?.cancel()
|
||||
socket?.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
||||
}
|
||||
|
||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
||||
listeners[handle.value]?.remove(listener)
|
||||
}
|
||||
|
||||
fun enableNotifications(handle: ATTHandles) {
|
||||
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
|
||||
}
|
||||
|
||||
fun read(handle: ATTHandles): ByteArray {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
|
||||
writeRaw(pdu)
|
||||
// wait for response placed into responses queue by the reader coroutine
|
||||
return readResponse()
|
||||
}
|
||||
|
||||
fun write(handle: ATTHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun write(handle: ATTCCCDHandles, value: ByteArray) {
|
||||
val lsb = (handle.value and 0xFF).toByte()
|
||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
||||
writeRaw(pdu)
|
||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
||||
try {
|
||||
readResponse()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No write response received: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeRaw(pdu: ByteArray) {
|
||||
output?.write(pdu)
|
||||
output?.flush()
|
||||
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
|
||||
// rename / specialize: read raw PDU directly from input stream (blocking)
|
||||
private fun readPDU(): ByteArray {
|
||||
val inp = input ?: throw IllegalStateException("Not connected")
|
||||
val buffer = ByteArray(512)
|
||||
val len = inp.read(buffer)
|
||||
if (len == -1) {
|
||||
disconnect()
|
||||
throw IllegalStateException("End of stream reached")
|
||||
}
|
||||
val data = buffer.copyOfRange(0, len)
|
||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return data
|
||||
}
|
||||
|
||||
// wait for a response PDU produced by the background reader
|
||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
||||
try {
|
||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
return resp.copyOfRange(1, resp.size)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw IllegalStateException("Interrupted while waiting for ATT response", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(device, type, true, true, 31, uuid),
|
||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||
arrayOf(type, true, true, device, 31, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d("ATTManager", "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
|
||||
} catch (e: Exception) {
|
||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e("ATTManager", errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.utils
|
||||
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.ATTManager
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
open class AirPodsBase(
|
||||
val modelNumber: List<String>,
|
||||
val name: String,
|
||||
val displayName: String = "AirPods",
|
||||
val manufacturer: String = "Apple Inc.",
|
||||
val budCaseRes: Int,
|
||||
val budsRes: Int,
|
||||
val leftBudsRes: Int,
|
||||
val rightBudsRes: Int,
|
||||
val caseRes: Int,
|
||||
val capabilities: Set<Capability>
|
||||
)
|
||||
enum class Capability {
|
||||
LISTENING_MODE,
|
||||
CONVERSATION_AWARENESS,
|
||||
STEM_CONFIG,
|
||||
HEAD_GESTURES,
|
||||
LOUD_SOUND_REDUCTION,
|
||||
PPE,
|
||||
SLEEP_DETECTION,
|
||||
HEARING_AID,
|
||||
ADAPTIVE_AUDIO,
|
||||
ADAPTIVE_VOLUME,
|
||||
SWIPE_FOR_VOLUME,
|
||||
HRM
|
||||
}
|
||||
|
||||
class AirPods: AirPodsBase(
|
||||
modelNumber = listOf("A1523", "A1722"),
|
||||
name = "AirPods 1",
|
||||
budCaseRes = R.drawable.airpods_1,
|
||||
budsRes = R.drawable.airpods_1_buds,
|
||||
leftBudsRes = R.drawable.airpods_1_left,
|
||||
rightBudsRes = R.drawable.airpods_1_right,
|
||||
caseRes = R.drawable.airpods_1_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods2: AirPodsBase(
|
||||
modelNumber = listOf("A2032", "A2031"),
|
||||
name = "AirPods 2",
|
||||
budCaseRes = R.drawable.airpods_2,
|
||||
budsRes = R.drawable.airpods_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_2_left,
|
||||
rightBudsRes = R.drawable.airpods_2_right,
|
||||
caseRes = R.drawable.airpods_2_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods3: AirPodsBase(
|
||||
modelNumber = listOf("A2565", "A2564"),
|
||||
name = "AirPods 3",
|
||||
budCaseRes = R.drawable.airpods_3,
|
||||
budsRes = R.drawable.airpods_3_buds,
|
||||
leftBudsRes = R.drawable.airpods_3_left,
|
||||
rightBudsRes = R.drawable.airpods_3_right,
|
||||
caseRes = R.drawable.airpods_3_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4: AirPodsBase(
|
||||
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||
name = "AirPods 4",
|
||||
budCaseRes = R.drawable.airpods_4,
|
||||
budsRes = R.drawable.airpods_4_buds,
|
||||
leftBudsRes = R.drawable.airpods_4_left,
|
||||
rightBudsRes = R.drawable.airpods_4_right,
|
||||
caseRes = R.drawable.airpods_4_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4ANC: AirPodsBase(
|
||||
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||
name = "AirPods 4 (ANC)",
|
||||
budCaseRes = R.drawable.airpods_4,
|
||||
budsRes = R.drawable.airpods_4_buds,
|
||||
leftBudsRes = R.drawable.airpods_4_left,
|
||||
rightBudsRes = R.drawable.airpods_4_right,
|
||||
caseRes = R.drawable.airpods_4_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro1: AirPodsBase(
|
||||
modelNumber = listOf("A2084", "A2083"),
|
||||
name = "AirPods Pro 1",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_1,
|
||||
budsRes = R.drawable.airpods_pro_1_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_1_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_1_right,
|
||||
caseRes = R.drawable.airpods_pro_1_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2Lightning: AirPodsBase(
|
||||
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2USBC: AirPodsBase(
|
||||
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro3: AirPodsBase(
|
||||
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||
name = "AirPods Pro 3",
|
||||
displayName = "AirPods Pro",
|
||||
budCaseRes = R.drawable.airpods_pro_3,
|
||||
budsRes = R.drawable.airpods_pro_3_buds,
|
||||
leftBudsRes = R.drawable.airpods_pro_3_left,
|
||||
rightBudsRes = R.drawable.airpods_pro_3_right,
|
||||
caseRes = R.drawable.airpods_pro_3_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.PPE,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HRM
|
||||
)
|
||||
)
|
||||
|
||||
data class AirPodsInstance(
|
||||
val name: String,
|
||||
val model: AirPodsBase,
|
||||
val actualModelNumber: String,
|
||||
val serialNumber: String?,
|
||||
val leftSerialNumber: String?,
|
||||
val rightSerialNumber: String?,
|
||||
val version1: String?,
|
||||
val version2: String?,
|
||||
val version3: String?,
|
||||
val aacpManager: AACPManager,
|
||||
val attManager: ATTManager?
|
||||
)
|
||||
|
||||
object AirPodsModels {
|
||||
val models: List<AirPodsBase> = listOf(
|
||||
AirPods(),
|
||||
AirPods2(),
|
||||
AirPods3(),
|
||||
AirPods4(),
|
||||
AirPods4ANC(),
|
||||
AirPodsPro1(),
|
||||
AirPodsPro2Lightning(),
|
||||
AirPodsPro2USBC(),
|
||||
AirPodsPro3()
|
||||
)
|
||||
|
||||
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
|
||||
return models.find { modelNumber in it.modelNumber }
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Utilities for Bluetooth cryptography operations, particularly for
|
||||
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||
*/
|
||||
object BluetoothCryptography {
|
||||
|
||||
/**
|
||||
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||
*
|
||||
* @param addr The Bluetooth address to verify
|
||||
* @param irk The Identity Resolving Key to use for verification
|
||||
* @return true if the address is verified as an RPA matching the IRK
|
||||
*/
|
||||
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||
val prand = rpa.copyOfRange(3, 6)
|
||||
val hash = rpa.copyOfRange(0, 3)
|
||||
val computedHash = ah(irk, prand)
|
||||
return hash.contentEquals(computedHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param key The key for encryption
|
||||
* @param data The data to encrypt
|
||||
* @return The encrypted data
|
||||
*/
|
||||
@SuppressLint("GetInstance")
|
||||
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val swappedKey = key.reversedArray()
|
||||
val swappedData = data.reversedArray()
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
return cipher.doFinal(swappedData).reversedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the ah function as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param k The IRK key
|
||||
* @param r The random part of the address
|
||||
* @return The hash part of the address
|
||||
*/
|
||||
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||
val rPadded = ByteArray(16)
|
||||
r.copyInto(rPadded, 0, 0, 3)
|
||||
val encrypted = e(k, rPadded)
|
||||
return encrypted.copyOfRange(0, 3)
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothServerSocket
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.bluetooth.le.AdvertiseCallback
|
||||
import android.bluetooth.le.AdvertiseData
|
||||
import android.bluetooth.le.AdvertiseSettings
|
||||
import android.bluetooth.le.BluetoothLeAdvertiser
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||
AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
|
||||
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
|
||||
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
|
||||
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
|
||||
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
|
||||
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
|
||||
}
|
||||
|
||||
|
||||
object CrossDevice {
|
||||
var initialized = false
|
||||
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
|
||||
private var serverSocket: BluetoothServerSocket? = null
|
||||
private var clientSocket: BluetoothSocket? = null
|
||||
private lateinit var bluetoothAdapter: BluetoothAdapter
|
||||
private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
|
||||
private const val MANUFACTURER_ID = 0x1234
|
||||
private const val MANUFACTURER_DATA = "ALN_AirPods"
|
||||
var isAvailable: Boolean = false // set to true when airpods are connected to another device
|
||||
var batteryBytes: ByteArray = byteArrayOf()
|
||||
var ancBytes: ByteArray = byteArrayOf()
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private const val PACKET_LOG_KEY = "packet_log"
|
||||
private var earDetectionStatus = listOf(false, false)
|
||||
var disconnectionRequested = false
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun init(context: Context) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
Log.d("CrossDevice", "Initializing CrossDevice")
|
||||
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
||||
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
|
||||
// startAdvertising()
|
||||
startServer()
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startServer() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (!bluetoothAdapter.isEnabled) return@launch
|
||||
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||
Log.d("CrossDevice", "Server started")
|
||||
while (serverSocket != null) {
|
||||
if (!bluetoothAdapter.isEnabled) {
|
||||
serverSocket?.close()
|
||||
break
|
||||
}
|
||||
if (clientSocket != null) {
|
||||
try {
|
||||
clientSocket!!.close()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val socket = serverSocket!!.accept()
|
||||
handleClientConnection(socket)
|
||||
} catch (e: IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "unused")
|
||||
private fun startAdvertising() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val settings = AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(true)
|
||||
.build()
|
||||
|
||||
val data = AdvertiseData.Builder()
|
||||
.setIncludeDeviceName(true)
|
||||
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
|
||||
.addServiceUuid(ParcelUuid(uuid))
|
||||
.build()
|
||||
try {
|
||||
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
|
||||
}
|
||||
Log.d("CrossDevice", "BLE Advertising started")
|
||||
}
|
||||
}
|
||||
|
||||
private val advertiseCallback = object : AdvertiseCallback() {
|
||||
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
|
||||
Log.d("CrossDevice", "BLE Advertising started successfully")
|
||||
}
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
fun setAirPodsConnected(connected: Boolean) {
|
||||
if (connected) {
|
||||
isAvailable = false
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
|
||||
} else {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
||||
// Reset state variables
|
||||
isAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReceivedPacket(packet: ByteArray) {
|
||||
if (clientSocket == null || clientSocket!!.outputStream != null) {
|
||||
return
|
||||
}
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
||||
}
|
||||
|
||||
private fun logPacket(packet: ByteArray, source: String) {
|
||||
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
|
||||
val logEntry = "$source: $packetHex"
|
||||
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
|
||||
logs.add(logEntry)
|
||||
sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleClientConnection(socket: BluetoothSocket) {
|
||||
Log.d("CrossDevice", "Client connected")
|
||||
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
clientSocket = socket
|
||||
val inputStream = socket.inputStream
|
||||
val buffer = ByteArray(1024)
|
||||
var bytes: Int
|
||||
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
|
||||
while (true) {
|
||||
try {
|
||||
bytes = inputStream.read(buffer)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
val s = serverSocket?.accept()
|
||||
if (s != null) {
|
||||
handleClientConnection(s)
|
||||
}
|
||||
break
|
||||
}
|
||||
var packet = buffer.copyOf(bytes)
|
||||
logPacket(packet, "Relay")
|
||||
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
|
||||
if (bytes == -1) {
|
||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
break
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||
ServiceManager.getService()?.disconnectForCD()
|
||||
disconnectionRequested = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(1000)
|
||||
disconnectionRequested = false
|
||||
}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
|
||||
isAvailable = true
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
|
||||
isAvailable = false
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
|
||||
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
|
||||
sendRemotePacket(batteryBytes)
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
|
||||
Log.d("CrossDevice", "Received ANC request")
|
||||
sendRemotePacket(ancBytes)
|
||||
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
|
||||
Log.d("CrossDevice", "Received connection status request")
|
||||
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
||||
} else {
|
||||
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||
isAvailable = true
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) }
|
||||
if (packet.size % 2 == 0) {
|
||||
val half = packet.size / 2
|
||||
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
|
||||
Log.d("CrossDevice", "Duplicated packet, trimming")
|
||||
packet = packet.sliceArray(0 until half)
|
||||
}
|
||||
}
|
||||
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
|
||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||
// ServiceManager.getService()?.sendPacket(packetInHex)
|
||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||
batteryBytes = trimmedPacket
|
||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
||||
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
|
||||
ServiceManager.getService()?.updateBattery()
|
||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||
ServiceManager.getService()?.sendBatteryNotification()
|
||||
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
|
||||
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
|
||||
ServiceManager.getService()?.sendANCBroadcast()
|
||||
ServiceManager.getService()?.updateNoiseControlWidget()
|
||||
ancBytes = trimmedPacket
|
||||
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
|
||||
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
|
||||
val newEarDetectionStatus = listOf(
|
||||
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
|
||||
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
|
||||
)
|
||||
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
|
||||
ServiceManager.getService()?.applicationContext?.sendBroadcast(
|
||||
Intent("me.kavishdevar.librepods.cross_device_island")
|
||||
)
|
||||
}
|
||||
earDetectionStatus = newEarDetectionStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendRemotePacket(byteArray: ByteArray) {
|
||||
if (clientSocket == null || clientSocket!!.outputStream == null) {
|
||||
return
|
||||
}
|
||||
clientSocket?.outputStream?.write(byteArray)
|
||||
clientSocket?.outputStream?.flush()
|
||||
logPacket(byteArray, "Sent")
|
||||
Log.d("CrossDevice", "Sent packet to remote device")
|
||||
}
|
||||
|
||||
fun notifyAirPodsConnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
fun notifyAirPodsDisconnectedRemotely(context: Context) {
|
||||
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.utils
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
|
||||
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
||||
|
||||
object HeadTracking {
|
||||
private val _orientation = MutableStateFlow(Orientation())
|
||||
val orientation = _orientation.asStateFlow()
|
||||
|
||||
private val _acceleration = MutableStateFlow(Acceleration())
|
||||
val acceleration = _acceleration.asStateFlow()
|
||||
|
||||
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
|
||||
private var isCalibrated = false
|
||||
private var o1Neutral = 19000
|
||||
private var o2Neutral = 0
|
||||
private var o3Neutral = 0
|
||||
|
||||
private const val CALIBRATION_SAMPLE_COUNT = 10
|
||||
private const val ORIENTATION_OFFSET = 5500
|
||||
|
||||
fun processPacket(packet: ByteArray) {
|
||||
val o1 = bytesToInt(packet[43], packet[44])
|
||||
val o2 = bytesToInt(packet[45], packet[46])
|
||||
val o3 = bytesToInt(packet[47], packet[48])
|
||||
|
||||
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
|
||||
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
|
||||
|
||||
if (!isCalibrated) {
|
||||
calibrationSamples.add(Triple(o1, o2, o3))
|
||||
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
|
||||
calibrate()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val orientation = calculateOrientation(o1, o2, o3)
|
||||
_orientation.value = orientation
|
||||
|
||||
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
|
||||
}
|
||||
|
||||
private fun calibrate() {
|
||||
if (calibrationSamples.size < 3) return
|
||||
|
||||
// Add offset during calibration
|
||||
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
|
||||
|
||||
isCalibrated = true
|
||||
}
|
||||
|
||||
@Suppress("UnusedVariable")
|
||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||
if (!isCalibrated) return Orientation()
|
||||
|
||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||
|
||||
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
|
||||
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
|
||||
|
||||
return Orientation(pitch, yaw)
|
||||
}
|
||||
|
||||
private fun bytesToInt(b1: Byte, b2: Byte): Int {
|
||||
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
calibrationSamples.clear()
|
||||
isCalibrated = false
|
||||
_orientation.value = Orientation()
|
||||
_acceleration.value = Acceleration()
|
||||
}
|
||||
}
|
||||
@@ -1,763 +0,0 @@
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.net.toUri
|
||||
import io.github.libxposed.api.XposedInterface
|
||||
import io.github.libxposed.api.XposedInterface.AfterHookCallback
|
||||
import io.github.libxposed.api.XposedModule
|
||||
import io.github.libxposed.api.XposedModuleInterface
|
||||
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
|
||||
import io.github.libxposed.api.annotations.AfterInvocation
|
||||
import io.github.libxposed.api.annotations.XposedHooker
|
||||
|
||||
private const val TAG = "AirPodsHook"
|
||||
private lateinit var module: KotlinModule
|
||||
@SuppressLint("DiscouragedApi", "PrivateApi")
|
||||
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) {
|
||||
init {
|
||||
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}")
|
||||
module = this
|
||||
}
|
||||
|
||||
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) {
|
||||
super.onPackageLoaded(param)
|
||||
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
|
||||
|
||||
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
|
||||
Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
|
||||
|
||||
try {
|
||||
if (param.isFirstPackage) {
|
||||
Log.i(TAG, "Loading native library for Bluetooth hook")
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
Log.i(TAG, "Native library loaded successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load native library: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.google.android.settings") {
|
||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||
try {
|
||||
val headerControllerClass = param.classLoader.loadClass(
|
||||
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||
|
||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||
"updateIcon",
|
||||
ImageView::class.java,
|
||||
String::class.java)
|
||||
|
||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
||||
|
||||
try {
|
||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
||||
"displayPreference",
|
||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
||||
|
||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.android.settings") {
|
||||
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
|
||||
try {
|
||||
val headerControllerClass = param.classLoader.loadClass(
|
||||
"com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
|
||||
|
||||
val updateIconMethod = headerControllerClass.getDeclaredMethod(
|
||||
"updateIcon",
|
||||
ImageView::class.java,
|
||||
String::class.java)
|
||||
|
||||
hook(updateIconMethod, BluetoothIconHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
|
||||
|
||||
try {
|
||||
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
|
||||
"displayPreference",
|
||||
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
|
||||
|
||||
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
|
||||
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a"
|
||||
private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference"
|
||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
||||
|
||||
private const val ANC_MODE_OFF = 1
|
||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
||||
private const val ANC_MODE_TRANSPARENCY = 3
|
||||
private const val ANC_MODE_ADAPTIVE = 4
|
||||
|
||||
private var currentAncMode = ANC_MODE_NOISE_CANCELLATION
|
||||
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterDisplayPreference(callback: AfterHookCallback) {
|
||||
try {
|
||||
val controller = callback.thisObject!!
|
||||
val preferenceScreen = callback.args[0]!!
|
||||
|
||||
val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context
|
||||
|
||||
val deviceField = controller.javaClass.getDeclaredField("mCachedDevice")
|
||||
deviceField.isAccessible = true
|
||||
val cachedDevice = deviceField.get(controller) ?: return
|
||||
|
||||
val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice")
|
||||
val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return
|
||||
|
||||
val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids")
|
||||
val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array<ParcelUuid>
|
||||
|
||||
if (uuids != null) {
|
||||
val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID }
|
||||
|
||||
if (isAirPods) {
|
||||
Log.i(TAG, "AirPods device detected in settings, injecting controls")
|
||||
|
||||
val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java)
|
||||
val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY)
|
||||
|
||||
if (existingPref != null) {
|
||||
Log.i(TAG, "LIBREPODS button already exists, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference")
|
||||
val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context)
|
||||
|
||||
val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java)
|
||||
setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY)
|
||||
|
||||
val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java)
|
||||
setTitleMethod.invoke(preference, "Open LibrePods")
|
||||
|
||||
val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java)
|
||||
setSummaryMethod.invoke(preference, "Control AirPods features")
|
||||
|
||||
val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java)
|
||||
setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage)
|
||||
|
||||
val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java)
|
||||
setOrderMethod.invoke(preference, 1000)
|
||||
|
||||
val intent = Intent().apply {
|
||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java)
|
||||
setIntentMethod.invoke(preference, intent)
|
||||
|
||||
val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass)
|
||||
addPreferenceMethod.invoke(preferenceScreen, preference)
|
||||
|
||||
Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e)
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class BluetoothIconHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterUpdateIcon(callback: AfterHookCallback) {
|
||||
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
|
||||
try {
|
||||
val imageView = callback.args[0] as ImageView
|
||||
val iconUri = callback.args[1] as String
|
||||
|
||||
val uri = iconUri.toUri()
|
||||
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
|
||||
Log.i(TAG, "Handling AirPods icon URI: $uri")
|
||||
|
||||
try {
|
||||
val context = imageView.context
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
val packageName = uri.authority
|
||||
val packageContext = context.createPackageContext(
|
||||
packageName,
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resPath = uri.pathSegments
|
||||
if (resPath.size >= 2 && resPath[0] == "drawable") {
|
||||
val resourceName = resPath[1]
|
||||
val resourceId = packageContext.resources.getIdentifier(
|
||||
resourceName, "drawable", packageName
|
||||
)
|
||||
|
||||
if (resourceId != 0) {
|
||||
val drawable = packageContext.resources.getDrawable(
|
||||
resourceId, packageContext.theme
|
||||
)
|
||||
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.alpha = 1.0f
|
||||
|
||||
callback.result = null
|
||||
|
||||
Log.i(TAG, "Successfully loaded icon from resource: $resourceName")
|
||||
} else {
|
||||
Log.e(TAG, "Resource not found: $resourceName")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading resource from URI $uri: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error accessing context: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getApplicationInfo(): ApplicationInfo {
|
||||
return super.applicationInfo
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANC_MODE_OFF = 1
|
||||
private const val ANC_MODE_NOISE_CANCELLATION = 2
|
||||
private const val ANC_MODE_TRANSPARENCY = 3
|
||||
private const val ANC_MODE_ADAPTIVE = 4
|
||||
|
||||
private var currentANCMode = ANC_MODE_NOISE_CANCELLATION
|
||||
|
||||
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
|
||||
private const val EXTRA_ANC_MODE = "anc_mode"
|
||||
private const val ANIMATION_DURATION = 250L
|
||||
|
||||
private fun addAirPodsControlsToDialog(volumeDialog: Any) {
|
||||
try {
|
||||
val contextField = volumeDialog.javaClass.getDeclaredField("mContext")
|
||||
contextField.isAccessible = true
|
||||
val context = contextField.get(volumeDialog) as Context
|
||||
|
||||
val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView")
|
||||
dialogViewField.isAccessible = true
|
||||
val dialogView = dialogViewField.get(volumeDialog) as ViewGroup
|
||||
|
||||
val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView")
|
||||
dialogRowsViewField.isAccessible = true
|
||||
val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup
|
||||
|
||||
Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}")
|
||||
|
||||
val existingContainer = dialogView.findViewWithTag<View>("airpods_container")
|
||||
if (existingContainer != null) {
|
||||
Log.d(TAG, "AirPods container already exists, ensuring visibility state")
|
||||
val drawer = existingContainer.findViewWithTag<View>("airpods_drawer_container")
|
||||
drawer?.visibility = View.GONE
|
||||
drawer?.alpha = 0f
|
||||
drawer?.translationY = 0f
|
||||
val button = existingContainer.findViewWithTag<ImageButton>("airpods_button")
|
||||
button?.visibility = View.VISIBLE
|
||||
button?.alpha = 1f
|
||||
if (button != null) {
|
||||
updateMainButtonIcon(context, button, currentANCMode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val newAirPodsButton = ImageButton(context).apply {
|
||||
tag = "airpods_button"
|
||||
|
||||
try {
|
||||
val airPodsPackage = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
val airPodsIconRes = airPodsPackage.resources.getIdentifier(
|
||||
"airpods", "drawable", "me.kavishdevar.librepods")
|
||||
|
||||
if (airPodsIconRes != 0) {
|
||||
val airPodsDrawable = airPodsPackage.resources.getDrawable(
|
||||
airPodsIconRes, airPodsPackage.theme)
|
||||
setImageDrawable(airPodsDrawable)
|
||||
} else {
|
||||
setImageResource(android.R.drawable.ic_media_play)
|
||||
Log.d(TAG, "Using fallback icon because airpods icon resource not found")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
setImageResource(android.R.drawable.ic_media_play)
|
||||
Log.e(TAG, "Failed to load AirPods icon: ${e.message}")
|
||||
}
|
||||
|
||||
val shape = GradientDrawable()
|
||||
shape.shape = GradientDrawable.RECTANGLE
|
||||
shape.setColor(Color.BLACK)
|
||||
background = shape
|
||||
|
||||
imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
|
||||
setPadding(24, 24, 24, 24)
|
||||
|
||||
val params = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
90
|
||||
)
|
||||
params.gravity = Gravity.CENTER
|
||||
params.setMargins(0, 0, 0, 0)
|
||||
layoutParams = params
|
||||
|
||||
setOnClickListener {
|
||||
Log.d(TAG, "AirPods button clicked, toggling drawer")
|
||||
val container = findAirPodsContainer(this)
|
||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
||||
if (drawerContainer != null && container != null) {
|
||||
if (drawerContainer.visibility == View.VISIBLE) {
|
||||
hideAirPodsDrawer(container, this, drawerContainer)
|
||||
} else {
|
||||
showAirPodsDrawer(container, this, drawerContainer)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Could not find container or drawer for toggle")
|
||||
}
|
||||
}
|
||||
|
||||
contentDescription = "AirPods Settings"
|
||||
}
|
||||
|
||||
val airPodsContainer = FrameLayout(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
tag = "airpods_container"
|
||||
}
|
||||
|
||||
newAirPodsButton.setOnLongClickListener {
|
||||
Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity")
|
||||
val intent = Intent().apply {
|
||||
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
context.startActivity(intent)
|
||||
try {
|
||||
val dismissMethod = volumeDialog.javaClass.getMethod("dismissH")
|
||||
dismissMethod.invoke(volumeDialog)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not dismiss volume dialog: ${e.message}")
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
val airPodsDrawer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
tag = "airpods_drawer_container"
|
||||
visibility = View.GONE
|
||||
alpha = 0f
|
||||
|
||||
val drawerShape = GradientDrawable()
|
||||
drawerShape.shape = GradientDrawable.RECTANGLE
|
||||
drawerShape.setColor(Color.BLACK)
|
||||
background = drawerShape
|
||||
|
||||
setPadding(16, 8, 16, 8)
|
||||
}
|
||||
|
||||
val buttonContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP
|
||||
}
|
||||
tag = "airpods_button_container"
|
||||
}
|
||||
|
||||
val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION)
|
||||
for (mode in modes) {
|
||||
val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton)
|
||||
airPodsDrawer.addView(modeOption)
|
||||
}
|
||||
|
||||
buttonContainer.addView(newAirPodsButton)
|
||||
|
||||
airPodsContainer.addView(airPodsDrawer)
|
||||
airPodsContainer.addView(buttonContainer)
|
||||
|
||||
val settingsViewField = try {
|
||||
val field = volumeDialog.javaClass.getDeclaredField("mSettingsView")
|
||||
field.isAccessible = true
|
||||
field.get(volumeDialog) as? View
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get settings view field: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
if (settingsViewField != null && settingsViewField.parent is ViewGroup) {
|
||||
val settingsParent = settingsViewField.parent as ViewGroup
|
||||
val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField)
|
||||
|
||||
if (settingsIndex >= 0) {
|
||||
settingsParent.addView(airPodsContainer, settingsIndex)
|
||||
Log.i(TAG, "Added AirPods controls before settings button")
|
||||
} else {
|
||||
settingsParent.addView(airPodsContainer)
|
||||
Log.i(TAG, "Added AirPods controls to the end of settings parent")
|
||||
}
|
||||
} else {
|
||||
dialogView.addView(airPodsContainer)
|
||||
Log.i(TAG, "Fallback: Added AirPods controls to dialog view")
|
||||
}
|
||||
|
||||
updateMainButtonIcon(context, newAirPodsButton, currentANCMode)
|
||||
|
||||
Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findViewIndexInParent(parent: ViewGroup, view: View): Int {
|
||||
for (i in 0 until parent.childCount) {
|
||||
if (parent.getChildAt(i) == view) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) {
|
||||
try {
|
||||
val pkgContext = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resName = when (mode) {
|
||||
ANC_MODE_OFF -> "noise_cancellation"
|
||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
||||
else -> "noise_cancellation"
|
||||
}
|
||||
|
||||
val resId = pkgContext.resources.getIdentifier(
|
||||
resName, "drawable", "me.kavishdevar.librepods"
|
||||
)
|
||||
|
||||
if (resId != 0) {
|
||||
val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme)
|
||||
button.setImageDrawable(drawable)
|
||||
button.setColorFilter(Color.WHITE)
|
||||
} else {
|
||||
button.setImageResource(getIconResourceForMode(mode))
|
||||
button.setColorFilter(Color.WHITE)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
button.setImageResource(getIconResourceForMode(mode))
|
||||
button.setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout {
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
setMargins(0, 6, 0, 6)
|
||||
}
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(24, 16, 24, 16)
|
||||
tag = "anc_mode_${mode}"
|
||||
|
||||
val icon = ImageView(context).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(60, 60).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
tag = "mode_icon_$mode"
|
||||
|
||||
try {
|
||||
val packageContext = context.createPackageContext(
|
||||
"me.kavishdevar.librepods",
|
||||
Context.CONTEXT_IGNORE_SECURITY
|
||||
)
|
||||
|
||||
val resourceName = when (mode) {
|
||||
ANC_MODE_OFF -> "noise_cancellation"
|
||||
ANC_MODE_TRANSPARENCY -> "transparency"
|
||||
ANC_MODE_ADAPTIVE -> "adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
|
||||
else -> "noise_cancellation"
|
||||
}
|
||||
|
||||
val resourceId = packageContext.resources.getIdentifier(
|
||||
resourceName, "drawable", "me.kavishdevar.librepods"
|
||||
)
|
||||
|
||||
if (resourceId != 0) {
|
||||
val drawable = packageContext.resources.getDrawable(
|
||||
resourceId, packageContext.theme
|
||||
)
|
||||
setImageDrawable(drawable)
|
||||
} else {
|
||||
setImageResource(getIconResourceForMode(mode))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
setImageResource(getIconResourceForMode(mode))
|
||||
Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}")
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
setColorFilter(Color.BLACK)
|
||||
} else {
|
||||
setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
|
||||
addView(icon)
|
||||
|
||||
background = if (isSelected) {
|
||||
createSelectedBackground(context)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
setOnClickListener {
|
||||
Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)")
|
||||
val container = findAirPodsContainer(this)
|
||||
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
|
||||
|
||||
if (currentANCMode == mode) {
|
||||
if (drawerContainer != null && container != null) {
|
||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
||||
}
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
currentANCMode = mode
|
||||
|
||||
val parentDrawer = parent as? ViewGroup
|
||||
if (parentDrawer != null) {
|
||||
for (i in 0 until parentDrawer.childCount) {
|
||||
val child = parentDrawer.getChildAt(i) as? LinearLayout
|
||||
if (child != null && child.tag.toString().startsWith("anc_mode_")) {
|
||||
val childModeStr = child.tag.toString().substringAfter("anc_mode_")
|
||||
val childMode = childModeStr.toIntOrNull() ?: -1
|
||||
val childIcon = child.findViewWithTag<ImageView>("mode_icon_${childMode}")
|
||||
|
||||
if (childMode == mode) {
|
||||
child.background = createSelectedBackground(context)
|
||||
childIcon?.setColorFilter(Color.BLACK)
|
||||
} else {
|
||||
child.background = null
|
||||
childIcon?.setColorFilter(Color.WHITE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(ACTION_SET_ANC_MODE).apply {
|
||||
setPackage("me.kavishdevar.librepods")
|
||||
putExtra(EXTRA_ANC_MODE, mode)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}")
|
||||
|
||||
|
||||
updateMainButtonIcon(context, mainButton, mode)
|
||||
|
||||
if (drawerContainer != null && container != null) {
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
hideAirPodsDrawer(container, mainButton, drawerContainer)
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSelectedBackground(context: Context): GradientDrawable {
|
||||
return GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
setColor(Color.WHITE)
|
||||
cornerRadius = 50f
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAirPodsContainer(view: View): ViewGroup? {
|
||||
var current: View? = view
|
||||
while (current != null) {
|
||||
if (current is ViewGroup && current.tag == "airpods_container") {
|
||||
return current
|
||||
}
|
||||
val parent = current.parent
|
||||
if (parent is ViewGroup && parent.tag == "airpods_container") {
|
||||
return parent
|
||||
}
|
||||
current = parent as? View
|
||||
}
|
||||
Log.w(TAG, "Could not find airpods_container ancestor")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
||||
Log.d(TAG, "Showing AirPods drawer")
|
||||
val selectedModeView = drawerContainer.findViewWithTag<View>("anc_mode_$currentANCMode")
|
||||
val selectedModeIcon = selectedModeView?.findViewWithTag<ImageView>("mode_icon_$currentANCMode")
|
||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
||||
|
||||
if (selectedModeView == null || selectedModeIcon == null) {
|
||||
Log.e(TAG, "Cannot find selected mode view or icon for show animation")
|
||||
|
||||
drawerContainer.alpha = 0f
|
||||
drawerContainer.visibility = View.VISIBLE
|
||||
|
||||
drawerContainer.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.start()
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(0f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.setStartDelay(ANIMATION_DURATION / 2)
|
||||
?.withEndAction {
|
||||
buttonContainer.visibility = View.GONE
|
||||
}
|
||||
?.start()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
drawerContainer.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
|
||||
val drawerHeight = drawerContainer.measuredHeight
|
||||
|
||||
drawerContainer.alpha = 0f
|
||||
drawerContainer.visibility = View.VISIBLE
|
||||
drawerContainer.translationY = -drawerHeight.toFloat()
|
||||
|
||||
drawerContainer.animate()
|
||||
.translationY(0f)
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.start()
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(0f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.setStartDelay(ANIMATION_DURATION / 3)
|
||||
?.withEndAction {
|
||||
buttonContainer.visibility = View.GONE
|
||||
}
|
||||
?.start()
|
||||
}
|
||||
|
||||
private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
|
||||
Log.d(TAG, "Hiding AirPods drawer")
|
||||
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
|
||||
|
||||
if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) {
|
||||
buttonContainer.alpha = 0f
|
||||
buttonContainer.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
buttonContainer?.animate()
|
||||
?.alpha(1f)
|
||||
?.setDuration(ANIMATION_DURATION / 2)
|
||||
?.start()
|
||||
|
||||
drawerContainer.animate()
|
||||
.translationY(-drawerContainer.height.toFloat())
|
||||
.alpha(0f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.setStartDelay(ANIMATION_DURATION / 4)
|
||||
.withEndAction {
|
||||
drawerContainer.visibility = View.GONE
|
||||
drawerContainer.translationY = 0f
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun getIconResourceForMode(mode: Int): Int {
|
||||
return when (mode) {
|
||||
ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode
|
||||
ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off
|
||||
ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass
|
||||
ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging
|
||||
else -> android.R.drawable.ic_lock_silent_mode_off
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLabelForMode(mode: Int): String {
|
||||
return when (mode) {
|
||||
ANC_MODE_OFF -> "Off"
|
||||
ANC_MODE_TRANSPARENCY -> "Transparency"
|
||||
ANC_MODE_ADAPTIVE -> "Adaptive"
|
||||
ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation"
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU 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.widgets
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.widget.RemoteViews
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class NoiseControlWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
val views = RemoteViews(context.packageName, R.layout.noise_control_widget)
|
||||
|
||||
val offIntent = Intent(context, NoiseControlWidget::class.java).apply {
|
||||
action = "ACTION_SET_ANC_MODE"
|
||||
putExtra("ANC_MODE", 1)
|
||||
}
|
||||
val transparencyIntent = Intent(context, NoiseControlWidget::class.java).apply {
|
||||
action = "ACTION_SET_ANC_MODE"
|
||||
putExtra("ANC_MODE", 3)
|
||||
}
|
||||
val adaptiveIntent = Intent(context, NoiseControlWidget::class.java).apply {
|
||||
action = "ACTION_SET_ANC_MODE"
|
||||
putExtra("ANC_MODE", 4)
|
||||
}
|
||||
val ancIntent = Intent(context, NoiseControlWidget::class.java).apply {
|
||||
action = "ACTION_SET_ANC_MODE"
|
||||
putExtra("ANC_MODE", 2)
|
||||
}
|
||||
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.widget_off_button,
|
||||
PendingIntent.getBroadcast(context, 0, offIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.widget_transparency_button,
|
||||
PendingIntent.getBroadcast(context, 1, transparencyIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.widget_adaptive_button,
|
||||
PendingIntent.getBroadcast(context, 2, adaptiveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
views.setOnClickPendingIntent(
|
||||
R.id.widget_anc_button,
|
||||
PendingIntent.getBroadcast(context, 3, ancIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
)
|
||||
ServiceManager.getService()?.updateNoiseControlWidget()
|
||||
appWidgetManager.updateAppWidget(appWidgetIds, views)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||
ServiceManager.getService()!!
|
||||
.aacpManager
|
||||
.sendControlCommand(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
mode.toByte()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.app.Application
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.room3.Room
|
||||
import io.github.libxposed.service.XposedService
|
||||
import io.github.libxposed.service.XposedServiceHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
||||
import me.kavishdevar.librepods.database.LibrePodsDatabase
|
||||
import me.kavishdevar.librepods.repository.AppDataRepository
|
||||
import me.kavishdevar.librepods.repository.AppleRepository
|
||||
import me.kavishdevar.librepods.repository.HeartRateRepository
|
||||
import me.kavishdevar.librepods.repository.RecordingRepository
|
||||
import me.kavishdevar.librepods.repository.WidgetConfigRepository
|
||||
import me.kavishdevar.librepods.utils.GestureFeedback
|
||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
|
||||
lateinit var database: LibrePodsDatabase
|
||||
private set
|
||||
|
||||
val appleRepository by lazy { AppleRepository(database.appleDao()) }
|
||||
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
|
||||
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
|
||||
|
||||
val recordingRepository by lazy { RecordingRepository(applicationContext) }
|
||||
val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
|
||||
|
||||
val healthConnectClient: HealthConnectClient? by lazy {
|
||||
val status = HealthConnectClient.getSdkStatus(this)
|
||||
if (status == HealthConnectClient.SDK_AVAILABLE) {
|
||||
HealthConnectClient.getOrCreate(this)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
System.loadLibrary("hiddenapi")
|
||||
|
||||
database = Room.databaseBuilder(
|
||||
applicationContext,
|
||||
LibrePodsDatabase::class.java,
|
||||
"librepods.db"
|
||||
).build()
|
||||
|
||||
XposedServiceHelper.registerListener(this)
|
||||
|
||||
runBlocking(Dispatchers.IO) {
|
||||
appDataRepository.awaitInitialized()
|
||||
}
|
||||
|
||||
BillingManager.provider = BillingProviderFactory.create(this)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
|
||||
GestureFeedback.init(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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package me.kavishdevar.librepods.audio
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodec.BufferInfo
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
class EldDecoder {
|
||||
|
||||
companion object {
|
||||
private val ASC = byteArrayOf(
|
||||
0xF8.toByte(),
|
||||
0xE6.toByte(),
|
||||
0x30,
|
||||
0x00
|
||||
)
|
||||
}
|
||||
|
||||
private val codec = MediaCodec.createDecoderByType(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC
|
||||
)
|
||||
|
||||
private val info = BufferInfo()
|
||||
|
||||
init {
|
||||
val format = MediaFormat.createAudioFormat(
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC,
|
||||
64_000,
|
||||
1
|
||||
)
|
||||
|
||||
format.setInteger(
|
||||
MediaFormat.KEY_AAC_PROFILE,
|
||||
MediaCodecInfo.CodecProfileLevel.AACObjectELD
|
||||
)
|
||||
|
||||
format.setByteBuffer(
|
||||
"csd-0",
|
||||
ByteBuffer.wrap(ASC)
|
||||
)
|
||||
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
}
|
||||
|
||||
fun decode(
|
||||
accessUnit: ByteArray,
|
||||
onPcm: (ByteArray) -> Unit
|
||||
) {
|
||||
val input = codec.dequeueInputBuffer(10_000)
|
||||
|
||||
if (input >= 0) {
|
||||
codec.getInputBuffer(input)?.apply {
|
||||
clear()
|
||||
put(accessUnit)
|
||||
}
|
||||
|
||||
codec.queueInputBuffer(
|
||||
input,
|
||||
0,
|
||||
accessUnit.size,
|
||||
0,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val output = codec.dequeueOutputBuffer(info, 0)
|
||||
|
||||
when {
|
||||
output >= 0 -> {
|
||||
val buffer = codec.getOutputBuffer(output)!!
|
||||
|
||||
val pcm = ByteArray(info.size)
|
||||
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
buffer.get(pcm)
|
||||
|
||||
codec.releaseOutputBuffer(output, false)
|
||||
|
||||
onPcm(pcm)
|
||||
}
|
||||
|
||||
output == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
// ignore
|
||||
}
|
||||
|
||||
output == MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||
break
|
||||
}
|
||||
|
||||
else -> {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
codec.stop()
|
||||
codec.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package me.kavishdevar.librepods.audio
|
||||
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
class WavWriter(
|
||||
file: File,
|
||||
private val sampleRate: Int = 64_000,
|
||||
private val channels: Int = 1,
|
||||
private val bitsPerSample: Int = 16
|
||||
) : AutoCloseable {
|
||||
|
||||
private val raf = RandomAccessFile(file, "rw")
|
||||
private var dataSize = 0L
|
||||
|
||||
init {
|
||||
writeHeader()
|
||||
}
|
||||
|
||||
fun write(pcm: ByteArray) {
|
||||
raf.write(pcm)
|
||||
dataSize += pcm.size
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
raf.seek(4)
|
||||
raf.writeIntLE((36 + dataSize).toInt())
|
||||
|
||||
raf.seek(40)
|
||||
raf.writeIntLE(dataSize.toInt())
|
||||
|
||||
raf.close()
|
||||
}
|
||||
|
||||
private fun writeHeader() {
|
||||
raf.writeBytes("RIFF")
|
||||
raf.writeIntLE(0)
|
||||
|
||||
raf.writeBytes("WAVE")
|
||||
|
||||
raf.writeBytes("fmt ")
|
||||
raf.writeIntLE(16)
|
||||
raf.writeShortLE(1)
|
||||
|
||||
raf.writeShortLE(channels.toShort())
|
||||
|
||||
raf.writeIntLE(sampleRate)
|
||||
|
||||
val byteRate = sampleRate * channels * bitsPerSample / 8
|
||||
raf.writeIntLE(byteRate)
|
||||
|
||||
val blockAlign = channels * bitsPerSample / 8
|
||||
raf.writeShortLE(blockAlign.toShort())
|
||||
|
||||
raf.writeShortLE(bitsPerSample.toShort())
|
||||
|
||||
raf.writeBytes("data")
|
||||
raf.writeIntLE(0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RandomAccessFile.writeIntLE(value: Int) {
|
||||
write(value and 0xff)
|
||||
write((value ushr 8) and 0xff)
|
||||
write((value ushr 16) and 0xff)
|
||||
write((value ushr 24) and 0xff)
|
||||
}
|
||||
|
||||
private fun RandomAccessFile.writeShortLE(value: Short) {
|
||||
write(value.toInt() and 0xff)
|
||||
write((value.toInt() ushr 8) and 0xff)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
object BillingManager {
|
||||
lateinit var provider: BillingProvider
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
import android.app.Activity
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface BillingProvider {
|
||||
val isPremium: StateFlow<Boolean>
|
||||
val price: StateFlow<String>
|
||||
fun purchase(activity: Activity)
|
||||
fun queryPurchases()
|
||||
fun restorePurchases()
|
||||
}
|
||||
+10
-14
@@ -16,22 +16,18 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
package me.kavishdevar.librepods.widgets
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
|
||||
class BatteryWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
ServiceManager.getService()?.updateBattery()
|
||||
object BillingProviderFactory {
|
||||
|
||||
fun create(context: Context): BillingProvider {
|
||||
return if (BuildConfig.PLAY_BUILD) {
|
||||
PlayBillingProvider(context)
|
||||
} else {
|
||||
FOSSBillingProvider(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.LibrePodsApplication
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
|
||||
|
||||
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
|
||||
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 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.seconds)
|
||||
_isPremium.value = true
|
||||
appDataRepository.updateState { it.copy(isPremium = true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryPurchases() {
|
||||
_isPremium.value = appDataRepository.state.value.isPremium
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
_isPremium.value = true
|
||||
appDataRepository.updateState { it.copy(isPremium = true) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||
import com.android.billingclient.api.BillingClient
|
||||
import com.android.billingclient.api.BillingClientStateListener
|
||||
import com.android.billingclient.api.BillingFlowParams
|
||||
import com.android.billingclient.api.BillingResult
|
||||
import com.android.billingclient.api.PendingPurchasesParams
|
||||
import com.android.billingclient.api.ProductDetails
|
||||
import com.android.billingclient.api.Purchase
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||
import com.android.billingclient.api.QueryProductDetailsParams
|
||||
import com.android.billingclient.api.QueryPurchasesParams
|
||||
import com.android.billingclient.api.acknowledgePurchase
|
||||
import com.android.billingclient.api.queryProductDetails
|
||||
import com.android.billingclient.api.queryPurchasesAsync
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.LibrePodsApplication
|
||||
|
||||
const val TAG = "PlayBillingProvider"
|
||||
|
||||
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||
|
||||
class PlayBillingProvider(
|
||||
context: Context
|
||||
) : BillingProvider, PurchasesUpdatedListener {
|
||||
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
|
||||
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
|
||||
|
||||
appDataRepository.updateState { it.copy(isPremium = 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.serialization.Serializable
|
||||
import me.kavishdevar.librepods.utils.redactMac
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
fun createBluetoothSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||
): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||
arrayOf(device, type, true, true, psm, uuid),
|
||||
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||
arrayOf(type, true, true, device, psm, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("createSocket<$psm>", "BluetoothSocket has ${constructors.size} constructors:")
|
||||
|
||||
constructors.forEachIndexed { index, constructor ->
|
||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
||||
Log.d("createSocket<$psm>", "Constructor $index: ($params)")
|
||||
}
|
||||
|
||||
var lastException: Exception? = null
|
||||
var attemptedConstructors = 0
|
||||
|
||||
for ((index, params) in constructorSpecs.withIndex()) {
|
||||
try {
|
||||
Log.d("createSocket<$psm>", "Trying constructor signature #${index + 1}")
|
||||
attemptedConstructors++
|
||||
|
||||
val paramTypes =
|
||||
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(*params) as BluetoothSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("createSocket<$psm>", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||
lastException = e
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
||||
Log.e("createSocket<$psm>", errorMessage)
|
||||
throw lastException ?: IllegalStateException(errorMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||
*
|
||||
* @param addr The Bluetooth address to verify
|
||||
* @param irk The Identity Resolving Key to use for verification
|
||||
* @return true if the address is verified as an RPA matching the IRK
|
||||
*/
|
||||
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||
val prand = rpa.copyOfRange(3, 6)
|
||||
val hash = rpa.copyOfRange(0, 3)
|
||||
val computedHash = ah(irk, prand)
|
||||
return hash.contentEquals(computedHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param key The key for encryption
|
||||
* @param data The data to encrypt
|
||||
* @return The encrypted data
|
||||
*/
|
||||
@SuppressLint("GetInstance")
|
||||
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val swappedKey = key.reversedArray()
|
||||
val swappedData = data.reversedArray()
|
||||
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||
return cipher.doFinal(swappedData).reversedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the ah function as specified in Bluetooth Core Specification
|
||||
*
|
||||
* @param k The IRK key
|
||||
* @param r The random part of the address
|
||||
* @return The hash part of the address
|
||||
*/
|
||||
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||
val rPadded = ByteArray(16)
|
||||
r.copyInto(rPadded, 0, 0, 3)
|
||||
val encrypted = e(k, rPadded)
|
||||
return encrypted.copyOfRange(0, 3)
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class MacAddress(val value: String) {
|
||||
init {
|
||||
require(value.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
|
||||
"Invalid MAC address format: $value"
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return value
|
||||
}
|
||||
|
||||
fun toRedactedString(): String {
|
||||
return value.redactMac()
|
||||
}
|
||||
|
||||
fun toNotificationId(): Int {
|
||||
return value
|
||||
.replace(":", "")
|
||||
.takeLast(8)
|
||||
.toUInt(16)
|
||||
.toInt()
|
||||
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+122
@@ -0,0 +1,122 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp
|
||||
|
||||
import android.util.Log
|
||||
import com.google.protobuf.ByteString
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceSetting
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
import kotlin.time.Duration
|
||||
|
||||
class RTBuddyManager(
|
||||
private val sendPacket: (AACPPacket) -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "RTBuddyManager"
|
||||
}
|
||||
|
||||
private var sequence = 0
|
||||
|
||||
private fun nextSequence(): Int {
|
||||
val next = sequence
|
||||
sequence = (sequence + 1) and 0x7FFFFFFF
|
||||
return next
|
||||
}
|
||||
|
||||
private fun observeSequence(data: SensorDataWX) {
|
||||
Log.d(TAG, "Received SensorDataWX seq=${data.seq}")
|
||||
}
|
||||
|
||||
fun handlePacket(packet: RTBuddyPacket) {
|
||||
when (val payload = packet.rtBuddyPayload) {
|
||||
is SensorDataWxBuddyPayload -> {
|
||||
val data = payload.data
|
||||
observeSequence(data)
|
||||
|
||||
// handleSensorData(data)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Unhandled RTBuddy payload: ${payload.descriptor}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private fun handleSensorData(data: SensorDataWX) {
|
||||
// if (data.hasServiceSettings()) {
|
||||
// when (data.serviceSettings.service) {
|
||||
// SensorServiceType.ACTIVITY,
|
||||
// SensorServiceType.DEVMOTION6 -> {
|
||||
// // Head tracking data/configuration
|
||||
// }
|
||||
//
|
||||
// SensorServiceType.HEARTRATE -> {
|
||||
// // Heart rate
|
||||
// }
|
||||
//
|
||||
// else -> {
|
||||
// Log.d(
|
||||
// TAG,
|
||||
// "Unhandled sensor service: ${data.serviceSettings.service}"
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
fun sendSensorData(
|
||||
data: SensorDataWX,
|
||||
): Boolean {
|
||||
val dataWithSequence = data.toBuilder()
|
||||
.setSeq(nextSequence())
|
||||
.build()
|
||||
|
||||
val packet = RTBuddyPacket.create(
|
||||
rtBuddyPayload = SensorDataWxBuddyPayload(
|
||||
data = dataWithSequence,
|
||||
),
|
||||
destination = PacketDestination.DEVICE,
|
||||
)
|
||||
|
||||
return sendPacket(packet)
|
||||
}
|
||||
|
||||
private fun sendSensorServiceSetting(
|
||||
service: SensorServiceType,
|
||||
configuration: ByteArray,
|
||||
): Boolean {
|
||||
val data = SensorDataWX.newBuilder()
|
||||
.setServiceSettings(
|
||||
SensorServiceSetting.newBuilder()
|
||||
.setService(service)
|
||||
.setSetting(2)
|
||||
.setConfiguration(
|
||||
ByteString.copyFrom(configuration)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
return sendSensorData(data)
|
||||
}
|
||||
|
||||
fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = sendSensorServiceSetting(
|
||||
service = sensorServiceType,
|
||||
configuration = byteArrayOf(0x01) + interval.toMicrosUINT32LE()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Duration.toMicrosUINT32LE(): ByteArray {
|
||||
val microseconds = this.inWholeMicroseconds
|
||||
return byteArrayOf(
|
||||
microseconds.toByte(),
|
||||
(microseconds shr 8).toByte(),
|
||||
(microseconds shr 16).toByte(),
|
||||
(microseconds shr 24).toByte()
|
||||
)
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class AACPPacketType(val value: Byte) {
|
||||
companion object {
|
||||
val CONNECT = AACPPacketType(0x00)
|
||||
val CONNECT_RESPONSE = AACPPacketType(0x01)
|
||||
val DISCONNECT = AACPPacketType(0x02)
|
||||
val DISCONNECT_RESPONSE = AACPPacketType(0x03)
|
||||
val MESSAGE = AACPPacketType(0x04)
|
||||
|
||||
fun fromByte(value: Byte): AACPPacketType {
|
||||
return when (value) {
|
||||
0x00.toByte() -> CONNECT
|
||||
0x01.toByte() -> CONNECT_RESPONSE
|
||||
0x02.toByte() -> DISCONNECT
|
||||
0x03.toByte() -> DISCONNECT_RESPONSE
|
||||
0x04.toByte() -> MESSAGE
|
||||
else -> AACPPacketType(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface AACPPacket {
|
||||
/**
|
||||
* The entire packet; contains the opcode and header
|
||||
*/
|
||||
val rawPacket: ByteArray
|
||||
get() = byteArrayOf(type.value, 0x00, service, 0x00, opcode.value, 0x00) + payload
|
||||
|
||||
val destination: PacketDestination
|
||||
|
||||
val type: AACPPacketType
|
||||
|
||||
val service: Byte
|
||||
|
||||
val opcode: Opcode
|
||||
|
||||
val payload: ByteArray
|
||||
|
||||
companion object {
|
||||
fun createUnknownPacket(
|
||||
opcode: Opcode,
|
||||
payload: ByteArray,
|
||||
destination: PacketDestination = PacketDestination.DEVICE,
|
||||
type: AACPPacketType = AACPPacketType.MESSAGE,
|
||||
service: Byte = 0x04,
|
||||
): AACPPacket {
|
||||
return UnknownAACPPacket(
|
||||
service = service,
|
||||
type = type,
|
||||
opcode = opcode,
|
||||
payload = payload,
|
||||
destination = destination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class UnknownAACPPacket (
|
||||
override val service: Byte = 0x04,
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE,
|
||||
override val opcode: Opcode,
|
||||
override val payload: ByteArray,
|
||||
override val destination: PacketDestination
|
||||
): AACPPacket
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSource
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.AudioSourceType
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class AudioSourcePacket(
|
||||
val audioSource: AudioSource,
|
||||
override val payload: ByteArray,
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.STEM_PRESS
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): AudioSourcePacket {
|
||||
if (packet.size < 9) {
|
||||
throw IllegalArgumentException("Data array too short to parse Audio Source Response")
|
||||
}
|
||||
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
val macAddress = MacAddress(payload.sliceArray(0..5).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":"))
|
||||
|
||||
val typeByte = payload[6]
|
||||
val type = AudioSourceType.fromByte(typeByte)
|
||||
|
||||
val audioSource = AudioSource(macAddress, type)
|
||||
|
||||
return AudioSourcePacket(audioSource, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
|
||||
import me.kavishdevar.librepods.devices.Battery
|
||||
import me.kavishdevar.librepods.devices.BatteryComponent
|
||||
import me.kavishdevar.librepods.devices.BatteryStatus
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
private const val TAG = "BatteryInfoPacket"
|
||||
|
||||
data class BatteryInfoPacket(
|
||||
val batteries: Set<Battery>,
|
||||
override val payload: ByteArray,
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode: Opcode = MessageOpcode.BATTERY_INFO
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): BatteryInfoPacket {
|
||||
val payload = packet.sliceArray(6 until packet.size)
|
||||
|
||||
var offset = 0
|
||||
val batteryCount = payload[offset].toInt()
|
||||
val batteries = mutableSetOf<Battery>()
|
||||
offset += 1
|
||||
for (i in 0 until batteryCount) {
|
||||
val componentByte = payload[offset]
|
||||
val component = BatteryComponent.fromAirPodsByte(componentByte)
|
||||
val levelByte = payload[offset + 2]
|
||||
val level = levelByte.toInt()
|
||||
val statusByte = payload[offset + 3]
|
||||
val status = BatteryStatus.fromAirPodsByte(statusByte)
|
||||
|
||||
Log.i(TAG, "parsed battery#$i: component=${component.name}, level=$level, status=${status.name}")
|
||||
|
||||
batteries.add(Battery(component, level, status))
|
||||
offset += 5
|
||||
}
|
||||
Log.i(TAG, "parsed Battery Info: $batteries")
|
||||
|
||||
return BatteryInfoPacket(batteries, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
private const val TAG = "ConnectedDevicesPacket"
|
||||
|
||||
data class ConnectedDevicesPacket(
|
||||
val connectedDevices: List<ConnectedDevice>,
|
||||
override val payload: ByteArray,
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.CONNECTED_DEVICES
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): ConnectedDevicesPacket {
|
||||
if (packet.size < 8) {
|
||||
throw IllegalArgumentException("Data array too short to parse Connected Devices Response")
|
||||
}
|
||||
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
|
||||
val deviceCount = payload[2].toInt()
|
||||
val devices = mutableListOf<ConnectedDevice>()
|
||||
|
||||
var offset = 3
|
||||
|
||||
for (i in 0 until deviceCount) {
|
||||
if (offset + 8 > payload.size) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Data array too short to parse all connected devices, returning what we have"
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
val macAddress = MacAddress(payload.sliceArray(offset until offset + 6).toHexString(HexFormat{ upperCase = true }).chunked(2).joinToString(":"))
|
||||
|
||||
val info1 = payload[offset + 6]
|
||||
val info2 = payload[offset + 7]
|
||||
|
||||
val existingDevice = devices.find { it.macAddress == macAddress }
|
||||
devices.add(ConnectedDevice(macAddress, info1, info2, existingDevice?.type))
|
||||
offset += 8
|
||||
}
|
||||
|
||||
return ConnectedDevicesPacket(devices, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommand
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class ControlCommandPacket(
|
||||
val controlCommand: ControlCommand,
|
||||
override val destination: PacketDestination,
|
||||
): AACPPacket {
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.CONTROL_COMMAND
|
||||
|
||||
override val payload: ByteArray = controlCommand.toAACPPayload()
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
data: ByteArray,
|
||||
destination: PacketDestination = PacketDestination.HOST
|
||||
): ControlCommandPacket {
|
||||
val payload = if (data.toHexString().startsWith("040004000900")) {
|
||||
data.copyOfRange(6, data.size)
|
||||
} else data
|
||||
|
||||
val controlCommand = ControlCommand.fromAACPPayload(payload)
|
||||
|
||||
return ControlCommandPacket(controlCommand, destination)
|
||||
}
|
||||
|
||||
fun create(
|
||||
controlCommand: ControlCommand,
|
||||
destination: PacketDestination = PacketDestination.DEVICE
|
||||
): ControlCommandPacket {
|
||||
return ControlCommandPacket(
|
||||
controlCommand = controlCommand,
|
||||
destination = destination,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ControlCommandPacket
|
||||
|
||||
if (controlCommand != other.controlCommand) return false
|
||||
if (destination != other.destination) return false
|
||||
if (!rawPacket.contentEquals(other.rawPacket)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = controlCommand.hashCode()
|
||||
result = 31 * result + destination.hashCode()
|
||||
result = 31 * result + rawPacket.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class CustomEqPacket(
|
||||
val customEq: CustomEq,
|
||||
override val destination: PacketDestination
|
||||
): AACPPacket {
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.CONTROL_COMMAND
|
||||
|
||||
override val payload: ByteArray = customEq.toAACPPayload()
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
destination: PacketDestination = PacketDestination.HOST
|
||||
): CustomEqPacket {
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
|
||||
val length = payload[0].toInt()
|
||||
require(length == 5) { "Invalid length for CustomEqPacket: $length" }
|
||||
|
||||
val state = payload[3].toInt()
|
||||
val low = payload[4].toInt()
|
||||
val mid = payload[5].toInt()
|
||||
val high = payload[6].toInt()
|
||||
|
||||
val customEq = CustomEq(state, low, mid, high)
|
||||
|
||||
return CustomEqPacket(customEq, destination)
|
||||
}
|
||||
|
||||
// TODO: not all customeq messages are sending custom eq. there is one to request the current custom eq settings.
|
||||
// this AACPPacket class only supports sending/receiving the custom eq settings, not other messages.
|
||||
fun create(
|
||||
customEq: CustomEq,
|
||||
destination: PacketDestination = PacketDestination.DEVICE
|
||||
): CustomEqPacket {
|
||||
return CustomEqPacket(
|
||||
customEq = customEq,
|
||||
destination = destination,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.ComponentStatus
|
||||
import me.kavishdevar.librepods.devices.DeviceComponent
|
||||
import me.kavishdevar.librepods.devices.DeviceComponentState
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
private const val TAG = "EarDetectionResponsePacket"
|
||||
|
||||
data class EarDetectionResponsePacket(
|
||||
val componentStates: Set<DeviceComponentState>,
|
||||
val isLeftPrimary: Boolean
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.EAR_DETECTION
|
||||
|
||||
override val payload: ByteArray = byteArrayOf(
|
||||
componentStates.first { it.component == DeviceComponent.LEFT }.status.toAirPodsByte(),
|
||||
componentStates.first { it.component == DeviceComponent.RIGHT }.status.toAirPodsByte()
|
||||
).let {
|
||||
if (isLeftPrimary) it else it.reversedArray()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
isLeftPrimary: Boolean
|
||||
): EarDetectionResponsePacket {
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
Log.d(
|
||||
TAG,
|
||||
"parsing Ear Detection Response: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
||||
)
|
||||
|
||||
val primaryStatus = payload[0]
|
||||
val secondaryStatus = payload[1]
|
||||
|
||||
val componentStates = mutableSetOf<DeviceComponentState>()
|
||||
|
||||
if (isLeftPrimary) {
|
||||
componentStates.addAll(
|
||||
listOf(
|
||||
DeviceComponentState(
|
||||
DeviceComponent.LEFT,
|
||||
ComponentStatus.fromAirPodsByte(primaryStatus)
|
||||
),
|
||||
DeviceComponentState(
|
||||
DeviceComponent.RIGHT,
|
||||
ComponentStatus.fromAirPodsByte(secondaryStatus)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
componentStates.addAll(
|
||||
listOf(
|
||||
DeviceComponentState(
|
||||
DeviceComponent.LEFT,
|
||||
ComponentStatus.fromAirPodsByte(secondaryStatus)
|
||||
),
|
||||
DeviceComponentState(
|
||||
DeviceComponent.RIGHT,
|
||||
ComponentStatus.fromAirPodsByte(primaryStatus)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.i(TAG, "parsed Ear Detection Response: $componentStates")
|
||||
|
||||
return EarDetectionResponsePacket(componentStates, isLeftPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.AirPodsModel
|
||||
import me.kavishdevar.librepods.devices.AirPodsSpecs
|
||||
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
private const val TAG = "InformationPacket"
|
||||
|
||||
data class InformationPacket(
|
||||
val metadata: AppleMetadata,
|
||||
override val payload: ByteArray
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.INFORMATION
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): InformationPacket {
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
|
||||
var index = 0
|
||||
while (index < payload.size && payload[index] != 0x00.toByte()) index++
|
||||
|
||||
val strings = mutableListOf<String>()
|
||||
while (index < payload.size) {
|
||||
// skip 0x00 bytes
|
||||
while (index < payload.size && payload[index] == 0x00.toByte()) index++
|
||||
if (index >= payload.size) break
|
||||
val start = index
|
||||
// find next 0x00 byte
|
||||
while (index < payload.size && payload[index] != 0x00.toByte()) index++
|
||||
val str = payload.sliceArray(start until index).decodeToString()
|
||||
strings.add(str)
|
||||
}
|
||||
|
||||
Log.i(TAG, "parse: strings: $strings")
|
||||
|
||||
strings.removeAt(0)
|
||||
|
||||
val model = AirPodsModel.fromModelNumber(strings.getOrNull(1)?: "A3063")
|
||||
|
||||
return InformationPacket(
|
||||
metadata = AppleMetadata(
|
||||
name = strings.getOrNull(0) ?: "",
|
||||
model = model,
|
||||
modelName = AirPodsSpecs.getSpec(model).displayName,
|
||||
modelNumber = strings.getOrNull(1) ?: "",
|
||||
manufacturer = strings.getOrNull(2) ?: "",
|
||||
serialNumber = strings.getOrNull(3) ?: "",
|
||||
leftSerialNumber = strings.getOrNull(8) ?: "",
|
||||
rightSerialNumber = strings.getOrNull(9) ?: "",
|
||||
version1 = strings.getOrNull(4) ?: "",
|
||||
version2 = strings.getOrNull(5) ?: "",
|
||||
version3 = strings.getOrNull(10) ?: "",
|
||||
hardwareRevision = strings.getOrNull(6) ?: "",
|
||||
updaterIdentifier = strings.getOrNull(7) ?: ""
|
||||
),
|
||||
payload = payload
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
private const val TAG = "MagicKeyResponsePacket"
|
||||
|
||||
data class MagicKeyResponsePacket(
|
||||
val magicKeys: Map<MagicKeyType, ByteArray>,
|
||||
override val payload: ByteArray,
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.MAGIC_KEYS_RESPONSE
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): MagicKeyResponsePacket {
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
val keyCount = payload[0].toInt()
|
||||
val keys = mutableMapOf<MagicKeyType, ByteArray>()
|
||||
var offset = 1
|
||||
for (i in 0 until keyCount) {
|
||||
Log.d(TAG, "Parsing Proximity Key $i")
|
||||
if (offset + 3 >= payload.size) {
|
||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||
}
|
||||
val keyType = payload[offset]
|
||||
val keyLength = payload[offset + 2].toInt()
|
||||
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
|
||||
offset += 4
|
||||
if (offset + keyLength > payload.size) {
|
||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||
}
|
||||
val key = ByteArray(keyLength)
|
||||
System.arraycopy(payload, offset, key, 0, keyLength)
|
||||
try {
|
||||
keys[MagicKeyType.fromByte(keyType)] = key
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "incorrect key type received: $keyType, ${key.toHexString()}", e)
|
||||
}
|
||||
offset += keyLength
|
||||
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.toHexString()}")
|
||||
}
|
||||
|
||||
return MagicKeyResponsePacket(keys, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyPayload
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.UnknownBuddyPayload
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class RTBuddyPacket(
|
||||
val rtBuddyPayload: RTBuddyPayload,
|
||||
|
||||
override val payload: ByteArray,
|
||||
override val destination: PacketDestination,
|
||||
) : AACPPacket {
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
override val opcode: Opcode = MessageOpcode.BUDDY_COMMAND
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
data: ByteArray,
|
||||
destination: PacketDestination = PacketDestination.HOST,
|
||||
): RTBuddyPacket {
|
||||
val payload = if (
|
||||
data.size >= 6 &&
|
||||
data[0] == AACPPacketType.MESSAGE.value &&
|
||||
data[4] == MessageOpcode.BUDDY_COMMAND.value
|
||||
) {
|
||||
data.copyOfRange(6, data.size)
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
require(payload.size >= 6) {
|
||||
"RTBuddy packet is too short: ${payload.size}"
|
||||
}
|
||||
|
||||
val descriptorValue =
|
||||
(payload[0].toUInt() and 0xFFu) or
|
||||
((payload[1].toUInt() and 0xFFu) shl 8) or
|
||||
((payload[2].toUInt() and 0xFFu) shl 16) or
|
||||
((payload[3].toUInt() and 0xFFu) shl 24)
|
||||
|
||||
val length =
|
||||
(payload[4].toInt() and 0xFF) or
|
||||
((payload[5].toInt() and 0xFF) shl 8)
|
||||
|
||||
require(payload.size >= 6 + length) {
|
||||
"RTBuddy payload truncated: expected $length bytes, got ${payload.size - 6}"
|
||||
}
|
||||
|
||||
val descriptor = RTBuddyDescriptor.fromValue(descriptorValue)
|
||||
val data = payload.copyOfRange(6, 6 + length)
|
||||
|
||||
val rtBuddyPayload: RTBuddyPayload = when (descriptor) {
|
||||
RTBuddyDescriptor.SENSOR_DATA_WX -> {
|
||||
SensorDataWxBuddyPayload(
|
||||
data = SensorDataWX.parseFrom(data),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
UnknownBuddyPayload(
|
||||
descriptor = descriptor,
|
||||
descriptorValue = descriptorValue,
|
||||
data = data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return RTBuddyPacket(
|
||||
rtBuddyPayload = rtBuddyPayload,
|
||||
payload = payload,
|
||||
destination = destination,
|
||||
)
|
||||
}
|
||||
fun create(
|
||||
rtBuddyPayload: RTBuddyPayload,
|
||||
destination: PacketDestination = PacketDestination.DEVICE,
|
||||
): RTBuddyPacket {
|
||||
val data = when (rtBuddyPayload) {
|
||||
is SensorDataWxBuddyPayload ->
|
||||
rtBuddyPayload.data.toByteArray()
|
||||
|
||||
is UnknownBuddyPayload ->
|
||||
rtBuddyPayload.data
|
||||
}
|
||||
|
||||
val descriptorValue = when (rtBuddyPayload) {
|
||||
is SensorDataWxBuddyPayload ->
|
||||
rtBuddyPayload.descriptor.value
|
||||
|
||||
is UnknownBuddyPayload ->
|
||||
rtBuddyPayload.descriptorValue
|
||||
}
|
||||
|
||||
require(data.size <= 0xFFFF) {
|
||||
"RTBuddy payload too large: ${data.size}"
|
||||
}
|
||||
|
||||
val payload = byteArrayOf(
|
||||
(descriptorValue and 0xFFu).toByte(),
|
||||
((descriptorValue shr 8) and 0xFFu).toByte(),
|
||||
((descriptorValue shr 16) and 0xFFu).toByte(),
|
||||
((descriptorValue shr 24) and 0xFFu).toByte(),
|
||||
(data.size and 0xFF).toByte(),
|
||||
((data.size shr 8) and 0xFF).toByte(),
|
||||
) + data
|
||||
|
||||
return RTBuddyPacket(
|
||||
rtBuddyPayload = rtBuddyPayload,
|
||||
payload = payload,
|
||||
destination = destination,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class RenamePacket(
|
||||
val name: String,
|
||||
override val destination: PacketDestination = PacketDestination.DEVICE,
|
||||
): AACPPacket {
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.RENAME
|
||||
|
||||
override val payload: ByteArray = byteArrayOf(0x01, name.length.toByte()) + name.toByteArray() + byteArrayOf(0x00)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
name: String,
|
||||
destination: PacketDestination = PacketDestination.DEVICE
|
||||
): RenamePacket {
|
||||
return RenamePacket(
|
||||
name = name,
|
||||
destination = destination,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressBud
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType
|
||||
import me.kavishdevar.librepods.devices.PacketDestination
|
||||
|
||||
data class StemPressPacket(
|
||||
val stemPressBud: StemPressBud,
|
||||
val stemPressType: StemPressType,
|
||||
override val payload: ByteArray,
|
||||
): AACPPacket {
|
||||
override val destination: PacketDestination = PacketDestination.HOST
|
||||
|
||||
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||
override val service: Byte = 0x04
|
||||
|
||||
override val opcode = MessageOpcode.STEM_PRESS
|
||||
|
||||
companion object {
|
||||
fun parse(
|
||||
packet: ByteArray,
|
||||
): StemPressPacket {
|
||||
if (packet.size != 8) {
|
||||
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
|
||||
}
|
||||
|
||||
val payload = packet.copyOfRange(6, packet.size)
|
||||
|
||||
val bud = StemPressBud.fromByte(payload[1])?: throw IllegalArgumentException("Invalid bud value: ${payload[1]}")
|
||||
val type = StemPressType.fromByte(payload[0])?: throw IllegalArgumentException("Invalid type value: ${payload[0]}")
|
||||
|
||||
return StemPressPacket(bud, type, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.types
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||
|
||||
enum class AudioSourceType(val value: Byte) {
|
||||
NONE(0x00),
|
||||
CALL(0x01),
|
||||
MEDIA(0x02),
|
||||
UNKNOWN_1(0x04),
|
||||
UNKNOWN_2(0x06),
|
||||
UNKNOWN(-1);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): AudioSourceType = entries.find { it.value == byte }?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
data class AudioSource(
|
||||
val mac: MacAddress, val type: AudioSourceType
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user