Compare commits

..

84 Commits

Author SHA1 Message Date
Kavish Devar e6ebbed674 docs: add trademark notice and librepods.org warning 2026-06-24 21:40:23 +05:30
Kavish Devar e34474f765 android: update gradle and AGP 2026-06-24 20:59:08 +05:30
Kavish Devar 4f910136dc android: remove unnecessary background modifier on m3 noise control settings 2026-06-23 17:29:56 +05:30
Kavish Devar 721819e792 android: change release notes variable name
0.3.1 -> 1.0.0
2026-06-23 17:26:26 +05:30
Kavish Devar bf3920718c android: remove '-play' from version name for release notes 2026-06-21 03:55:54 +05:30
Kavish Devar cb0c46dc33 android: fix left/right serial numbers in m3e 2026-06-21 03:51:15 +05:30
Kavish Devar cd40975a1f android: check premium for enabling Apple UI 2026-06-20 20:40:22 +05:30
Kavish Devar 790e396345 android: add material 3 expressive theme
All of the new UI was written by hand!
also fixed upgraded being set to false in FOSS builds when opening App settings. should've been a different commit, but ¯\_(ツ)_/¯
2026-06-20 20:16:59 +05:30
Kavish Devar 633d036dd7 android: match ios27 icon button and top bar style 2026-06-13 15:48:54 +05:30
Kavish Devar 7341e41837 android: add custom EQ settings (ios27)
will be released into stable as soon as I implement capability parsing
2026-06-13 04:58:14 +05:30
Kavish Devar bffb5c8b3e android: consider all A17 devices supported
Google's statements were ambiguous on if the workaround will be available on A17 or still on OEM to implement this specific patch. But the app does work on OneUI 9
2026-06-10 14:43:36 +05:30
Kavish Devar aca4373ec4 android: fix widget not showing charging when charge limit is enabled 2026-06-10 14:39:46 +05:30
Kavish Devar 8804197760 android: bump version 2026-06-04 11:19:33 +05:30
Kavish Devar 57d692c4ae android: refactor AACP socket handling 2026-06-01 14:53:33 +05:30
jiggles 0477674810 android: set audiofocus none in popup video views (#611)
fixed popups interrupting media playback
2026-06-01 16:29:59 +05:30
Kavish Devar c1093fbe24 android: fix FOSS upgraded being written false on app launch
fixes #610
2026-06-01 12:43:57 +05:30
Kavish Devar 0f50eab788 android: move ATT code to viewmodel from screens and enable notifications 2026-05-31 00:48:20 +05:30
Kavish Devar 1381022b2e android: fix name field being empty on rename screen launch 2026-05-31 00:48:20 +05:30
Kavish Devar af4261485a android: fix rework ATT connection 2026-05-31 00:48:20 +05:30
Kavish Devar 571db0ebde android: listen to UUID broadcasts 2026-05-31 00:48:20 +05:30
Kavish Devar 3c3c0edffd android: add message for Play users who unlocked FOSS upgrade 2026-05-31 00:48:20 +05:30
Kavish Devar f86d7b9aca android: fix PLAY_BUILD flag 2026-05-31 00:48:20 +05:30
Kavish Devar 29a914c2ff docs: update 2-way audio description for Android in project README 2026-05-19 02:05:07 +05:30
Kavish Devar 3f2a7df749 docs: remove installation instructions from project README 2026-05-16 10:10:06 +05:30
Kavish Devar f9367f4445 docs: fix root requirement link in repo README 2026-05-16 10:01:57 +05:30
Kavish Devar 0101428108 docs: add screenshots header to android README 2026-05-16 00:11:49 +05:30
Kavish Devar 37c1837d0b docs: fix badge label encoding in README 2026-05-15 23:42:41 +05:30
Kavish Devar 154ed17c05 docs: update README 2026-05-15 23:36:11 +05:30
Kavish Devar 362e34202a ci: rename linux ci 2026-05-15 22:25:31 +05:30
Kavish Devar 5bc5079e13 update android bug report template 2026-05-11 22:12:39 +05:30
Kavish Devar 64d233d427 android: use issue template 2026-05-11 22:10:14 +05:30
Kavish Devar ea2c2b811b update app source options 2026-05-11 21:55:25 +05:30
Kavish Devar 6f28df734e ci: build only unsigned debug apks on PR 2026-05-11 21:48:27 +05:30
Kavish Devar c15e15a6b7 update app source options in android bug report template 2026-05-11 21:44:35 +05:30
thisisAcidic 75a52cdfd7 add GitHub issue templates (#565)
* chore: add GitHub issue templates for Android, Linux, and feature requests

* remove btl2capfix module option and add instructions for logs

Updated bug report template for Android to clarify logging instructions and options for rooted devices.

* Refine Linux bug report template details

Updated labels and descriptions for clarity in the bug report template.

---------

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

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

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

* Revise Xposed workaround note in README

Updated warning about Xposed/LSPosed workaround for compatibility.
2026-05-01 20:06:42 +05:30
Kavish Devar 845f26192c android: make head tracking screen scrollable 2026-04-30 12:53:02 +05:30
Kavish Devar 3321bb1c43 android: bump version 2026-04-30 01:07:43 +05:30
Kavish Devar c7a5cb2d8c android: fix crash in listening mode widget when service is null 2026-04-30 01:03:51 +05:30
Kavish Devar 7b81411417 android: fix media not resuming when using single AirPod 2026-04-30 01:00:15 +05:30
Kavish Devar d80f2275a1 android: remove NativeBridge calls from app settings 2026-04-30 00:58:42 +05:30
Kavish Devar 795bebc6ae android: use pressandhold settings when cycling modes 2026-04-28 20:29:00 +05:30
Kavish Devar 4ef3e4d4da ci: post nightly release to #android-ci on discord 2026-04-28 18:17:04 +05:30
Kavish Devar b88b14de15 move things around 2026-04-28 17:27:11 +05:30
Kavish Devar 7cd4dfa3e0 remove head tracking 2026-04-28 17:27:00 +05:30
Kavish Devar 30d16e9977 docs: add some docs
I made this a while back, it is very incomplete, just adding in case anyone's looking for the opcodes
2026-04-28 16:19:30 +05:30
Kavish Devar ddcf15eefe android: clear root module build dir on each run 2026-04-28 12:21:46 +05:30
Kavish Devar 3e89d7f41b android: version bump 2026-04-28 12:13:14 +05:30
Kavish Devar 9eb6010a25 android: connect to audio when reconnecting to last connected device 2026-04-28 12:12:45 +05:30
Kavish Devar 60e865fc1f android: call connect to audio even if BLUETOOTH_PRIVILEGED/MODIFY_PHONE_STATE is not granted 2026-04-28 12:12:07 +05:30
Kavish Devar 629b7b917e android: fix crash on some devices not properly closing socket 2026-04-28 12:10:47 +05:30
Kavish Devar d4ee741224 android: add disconnect button 2026-04-28 12:10:00 +05:30
Kavish Devar 4c8b0d720d android: fix crash in reading call control settings 2026-04-28 12:08:22 +05:30
Kavish Devar e20b0f7fd7 android: fix adaptive audio strength slider not being flipped
why... why, apple?
2026-04-28 12:07:00 +05:30
Kavish Devar b64ff1d09e android: catch exceptions when closing IslandWindow 2026-04-28 12:04:59 +05:30
Kavish Devar 37056c6de7 android: fix icon size in select list 2026-04-28 12:04:29 +05:30
Kavish Devar 3a636e37a4 android: increase bottom padding on all screens 2026-04-28 12:04:08 +05:30
Kavish Devar 136e3e8995 android: do not log writeRaw in ATTManager if socket unavailable 2026-04-28 12:03:51 +05:30
Kavish Devar e39c1cfeba android: fix ControlCommand.parseFromBytes outofbounds crash 2026-04-28 12:03:26 +05:30
Kavish Devar b06d780eee android: fix xposed state being set true onResume 2026-04-27 13:13:40 +05:30
Kavish Devar 70f420dedb android: fix text color in email bottom sheet 2026-04-27 10:17:33 +05:30
Kavish Devar 23193ceb39 android: load native hook from split apks when base fails 2026-04-27 10:08:05 +05:30
Kavish Devar cb246d1287 android: update dialog and add app info to incompatible page 2026-04-26 17:36:17 +05:30
Kavish Devar 95cd677da9 android: fix bypass on pixels on older A16 version; also check Xposed scope for compatibility 2026-04-26 16:23:29 +05:30
Kavish Devar 0d049d93fb ci: use latest release tag for changelog 2026-04-26 16:17:02 +05:30
Kavish Devar 469d948061 android: add xposed check and email form
too many emails with absolutely no content
2026-04-26 05:05:20 +05:30
Kavish Devar f5d92768e2 android: rename util->utils in normal flavor 2026-04-26 01:07:12 +05:30
Kavish Devar 8cb2951bc6 ci: fix typo in release bundle asset name 2026-04-26 00:46:54 +05:30
Kavish Devar bb578dab23 ci: upload artifacts separately 2026-04-26 00:43:09 +05:30
Kavish Devar b1b47048a3 ci: fix keystore and add manual trigger 2026-04-26 00:29:14 +05:30
Hugo Holmqvist bf09300dfe android: fix bypass_device_check.v2 being silently ignored (#543) 2026-04-26 00:21:58 +05:30
Kavish Devar 70165232c0 ci: fix ndk 2026-04-26 00:18:03 +05:30
Kavish Devar aabbc902cb ci: release nightly builds on all changes 2026-04-25 23:58:52 +05:30
Kavish Devar 0ee7056600 android: fix versionName in builds 2026-04-25 23:58:23 +05:30
143 changed files with 12889 additions and 12066 deletions
@@ -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
+176 -64
View File
@@ -1,100 +1,212 @@
name: Build APK and root module (and create nightly release) name: Android CI
on: on:
push: push:
branches: branches:
- '*' - '*'
paths:
- 'android/**'
- 'root-module-manual/**'
pull_request:
paths: paths:
- 'android/**' - 'android/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: branch:
description: 'Create a nightly release' description: Branch to build
required: true required: true
type: boolean default: main
default: false
custom_notes:
description: 'Custom updates to add to What''s Changed section'
required: false
type: string
workflow_call: workflow_call:
jobs: jobs:
build-debug-apk: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
app_version: ${{ steps.version.outputs.app_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: zulu
java-version: 21 java-version: 21
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
- name: Build debug APK - name: Decode keystore
run: ./gradlew assembleDebug 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 working-directory: android
- name: Upload artifact - name: Build release artifacts
uses: actions/upload-artifact@v4 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: with:
name: Debug APK name: apk-release
path: android/app/build/outputs/apk/**/*.apk path: release/*release.apk
nightly-release:
- uses: actions/upload-artifact@v4
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 runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true' needs: build
needs: build-debug-apk
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Export APK_NAME for later use with:
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV name: apk-release
- name: Rename .apk file path: artifacts/apk-release
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
- name: Decode keystore file
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
- name: Install apksigner
run: sudo apt-get update && sudo apt-get install -y apksigner
- name: Sign APK
run: |
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
- name: Verify APK
run: apksigner verify "./$APK_NAME"
- name: Fetch the latest non-nightly release tag
id: fetch-tag
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
- name: Retrieve commits since the last release
id: get-commits
run: |
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
echo "::set-output name=commits::${COMMITS}"
- name: Prepare release notes
id: release-notes
run: |
# Create a temporary file for release notes
NOTES_FILE=$(mktemp)
# Process custom notes if they exist - uses: actions/download-artifact@v4
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then with:
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}" name: apk-debug
path: artifacts/apk-debug
# Check if custom notes already have bullet points or GitHub-style formatting - uses: actions/download-artifact@v4
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then with:
# Already formatted, use as is name: root-module-release
echo "$CUSTOM_NOTES" > "$NOTES_FILE" path: artifacts/root-module-release
else
# Add bullet point formatting
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
fi
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT - uses: actions/download-artifact@v4
- name: Zip root-module directory with:
run: sh ./build-magisk-module.sh name: root-module-debug
- name: Delete release if exist then create release path: artifacts/root-module-debug
- id: prev
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag TAG=$(gh release list \
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes --limit 1 \
--json tagName \
--jq '.[0].tagName')
echo "tag=$TAG" >> $GITHUB_OUTPUT
- id: changelog
run: |
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
else
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
fi
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- id: tag
run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.tag.outputs.tag }}" \
artifacts/**/* \
-t "Nightly ${{ needs.build.outputs.short_sha }}" \
--notes "${{ steps.changelog.outputs.notes }}" \
--prerelease
- name: Get timestamp
id: timestamp
run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
- name: Post to Discord
run: |
curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \
-H "Content-Type: application/json" \
-d '{
"embeds": [
{
"title": "LibrePods Nightly Build",
"description": "Download the latest debug and release APKs.",
"color": 253060,
"fields": [
{
"name": "Changelog",
"value": "${{ steps.changelog.outputs.notes }}",
"inline": false
}
],
"timestamp": "${{ steps.timestamp.outputs.timestamp }}",
"footer": {
"text": "GitHub Actions"
}
}
],
"components": [
{
"type": 1,
"components": [
{
"type": 2,
"label": "Download Release APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk"
},
{
"type": 2,
"label": "Download Debug APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk"
}
]
}
]
}'
+1 -1
View File
@@ -1,4 +1,4 @@
name: Linux Build & Release name: Linux CI (Rust)
on: on:
push: push:
-164
View File
@@ -1,164 +0,0 @@
# Bluetooth Low Energy (BLE) - Apple Proximity Pairing Message
This document describes how the AirPods BLE "Proximity Pairing Message" is parsed and interpreted in the application. This message is broadcast by Apple devices (such as AirPods) and contains key information about the device's state, battery, and other properties.
## Overview
When scanning for BLE devices, the application looks for manufacturer data with Apple's ID (`0x004C`). If the data starts with `0x07`, it is identified as a Proximity Pairing Message. The message contains various fields, each representing a specific property of the AirPods.
## Proximity Pairing Message Structure
| Byte Index | Field Name | Description | Example Value(s) |
|------------|-------------------------|---------------------------------------------------------|--------------------------|
| 0 | Prefix | Message type (should be `0x07` for proximity pairing) | `0x07` |
| 1 | Length | Length of the message | `0x12` |
| 2 | Pairing Mode | `0x01` = Paired, `0x00` = Pairing mode | `0x01`, `0x00` |
| 3-4 | Device Model | Big-endian: [3]=high, [4]=low | `0x0E20` (AirPods Pro) |
| 5 | Status | Bitfield, see below | `0x62` |
| 6 | Pods Battery Byte | Nibbles for left/right pod battery | `0xA7` |
| 7 | Flags & Case Battery | Upper nibble: case battery, lower: flags | `0xB3` |
| 8 | Lid Indicator | Bits for lid state and open counter | `0x09` |
| 9 | Device Color | Color code | `0x02` |
| 10 | Connection State | Enum, see below | `0x04` |
| 11-26 | Encrypted Payload | 16 bytes, not parsed | |
## Field Details
### Device Model
| Value (hex) | Model Name |
|-------------|--------------------------|
| 0x0220 | AirPods 1st Gen |
| 0x0F20 | AirPods 2nd Gen |
| 0x1320 | AirPods 3rd Gen |
| 0x1920 | AirPods 4th Gen |
| 0x1B20 | AirPods 4th Gen (ANC) |
| 0x0A20 | AirPods Max |
| 0x1F20 | AirPods Max (USB-C) |
| 0x0E20 | AirPods Pro |
| 0x1420 | AirPods Pro 2nd Gen |
| 0x2420 | AirPods Pro 2nd Gen (USB-C) |
### Status Byte (Bitfield)
| Bit | Meaning | Value if Set |
|-----|--------------------------------|-------------|
| 0 | Right Pod In Ear (XOR logic) | true |
| 1 | Right Pod In Ear (XOR logic) | true |
| 2 | Both Pods In Case | true |
| 3 | Left Pod In Ear (XOR logic) | true |
| 4 | One Pod In Case | true |
| 5 | Primary Pod (1=Left, 0=Right) | true/false |
| 6 | This Pod In Case | true |
### Ear Detection Logic
The in-ear detection uses XOR logic based on:
- Whether the right pod is primary (`areValuesFlipped`)
- Whether this pod is in the case (`isThisPodInTheCase`)
```cpp
bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
```
### Primary Pod
Determined by bit 5 of the status byte:
- `1` = Left pod is primary
- `0` = Right pod is primary
This affects:
1. Battery level interpretation (which nibble corresponds to which pod)
2. Microphone assignment
3. Ear detection logic
### Microphone Status
The active microphone is determined by:
```cpp
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
```
### Pods Battery Byte
- Upper nibble: one pod battery (depends on primary)
- Lower nibble: other pod battery
| Value | Meaning |
|-------|----------------|
| 0x0-0x9 | 0-90% (x10) |
| 0xA-0xE | 100% |
| 0xF | Not available|
### Flags & Case Battery Byte
- Upper nibble: case battery (same encoding as pods)
- Lower nibble: flags
#### Flags (Lower Nibble)
| Bit | Meaning |
|-----|--------------------------|
| 0 | Right Pod Charging (XOR) |
| 1 | Left Pod Charging (XOR) |
| 2 | Case Charging |
### Lid Indicator
| Bits | Meaning |
|------|------------------------|
| 0-2 | Lid Open Counter |
| 3 | Lid State (0=Open, 1=Closed) |
### Device Color
| Value | Color |
|-------|-------------|
| 0x00 | White |
| 0x01 | Black |
| 0x02 | Red |
| 0x03 | Blue |
| 0x04 | Pink |
| 0x05 | Gray |
| 0x06 | Silver |
| 0x07 | Gold |
| 0x08 | Rose Gold |
| 0x09 | Space Gray |
| 0x0A | Dark Blue |
| 0x0B | Light Blue |
| 0x0C | Yellow |
| 0x0D+ | Unknown |
### Connection State
| Value | State |
|-------|--------------|
| 0x00 | Disconnected |
| 0x04 | Idle |
| 0x05 | Music |
| 0x06 | Call |
| 0x07 | Ringing |
| 0x09 | Hanging Up |
| 0xFF | Unknown |
## Example Message
| Byte Index | Example Value | Description |
|------------|--------------|----------------------------|
| 0 | 0x07 | Proximity Pairing Message |
| 1 | 0x12 | Length |
| 2 | 0x01 | Paired |
| 3-4 | 0x0E 0x20 | AirPods Pro |
| 5 | 0x62 | Status |
| 6 | 0xA7 | Pods Battery |
| 7 | 0xB3 | Flags & Case Battery |
| 8 | 0x09 | Lid Indicator |
| 9 | 0x02 | Device Color |
| 10 | 0x04 | Connection State (Idle) |
---
For further details, see [`BleManager`](linux/ble/blemanager.cpp) and [`BleScanner`](linux/ble/blescanner.cpp).
+184 -117
View File
@@ -1,114 +1,99 @@
<p align="center"> > [!WARNING]
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" /> > 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.
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" /> >
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" /> > Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz)
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
<a href="https://discord.gg/HhG4ycVum4"> <picture>
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" /> <source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
</a> <source media="(prefers-color-scheme: light)" srcset="./imgs/banner.png" />
</p> <img alt="LibrePods" src="./imgs/banner.png" />
</picture>
>[!IMPORTANT] <div align="center" style="margin: 20px 0px;">
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then. <a href="https://github.com/kavishdevar/librepods/releases/latest">
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?label=GitHub%20Downloads" />
![LibrePods Banner](./imgs/banner.png) </a>
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml">
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml/badge.svg" />
</a>
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml">
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml/badge.svg" />
</a>
<a href="https://github.com/kavishdevar/librepods/issues">
<img src="https://img.shields.io/github/issues/kavishdevar/librepods" />
</a>
<a href="https://discord.gg/HhG4ycVum4">
<img src="https://img.shields.io/discord/1441416992027574375?logoColor=white&color=5865F2&label=Discord" />
</a>
</div>
# What is LibrePods? # What is LibrePods?
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
# Device Compatibility # Feature availability
| Status | Device | Features | | Feature | Linux | Android |
| ------ | --------------------- | ---------------------------------------------------------- | | ----------------------------------------------------------- | ----- | ------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | | Changing Listening Mode | ✅ | ✅ |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | | Ear detection | ✅ | ✅ |
| ✅ | AirPods Max | Fully supported (client shows unsupported features) | | Battery status | ✅ | ✅ |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | | Renaming AirPods <details><summary>Note for Android</summary>On Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.</details> | ✅ | ✅ |
| Loud Sound Reduction | 🔴 | ⚪ |
| Head Gestures | ⛔ | ✅ |
| Conversational Awareness | ✅ | ✅ |
| Automatically connect to AirPods | ✅ | ✅ |
| Hearing Aid | 🔴 | ⚪ |
| Transparency Mode customization | 🔴 | ⚪ |
| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ |
| <details><summary>Other accessibility configs (click to expand)</summary><ul><li>Press speed</li><li>Press and Hold duration</li><li>Noise Cancellation with single AirPod</li><li>Volume control on swipe</li><li>Volume swipe speed</li></ul></details> | 🔴 | ✅ |
| <details><summary>Other general configs</summary><ul><li>Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)</li><li>Configure call controls</li><li>Personalized volume</li><li>Loud Sound Reduction (needs <a href="#vendorid-spoofing">VendorID spoofing</a>)</li><li>Microphone side</li><li>Pause media when falling asleep (needs a recent firmware)</li><li>Enable <code>Off listening mode</code> to switch to <code>Off</code></li></ul></details> | 🔴 | ✅ |
| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ |
| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 |
| [Find My](#find-my) | ❓ | ❓ |
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS). | Symbol | Meaning |
| ------ | ------------------------------------------------------------------- |
| ✅ | Implemented and works well |
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
| 🔴 | Not implemented yet; planned |
| ⛔ | Will not be implemented |
| ❓ | Unknown |
# Key Features ## Find My
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press The following features related to Find My are planned, but require further RE and might need root on Android:
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
- **Battery Status**: Accurate battery levels
- **Head Gestures**: Answer calls just by nodding your head
- **Conversational Awareness**: Volume automatically lowers when you speak
- **Hearing Aid\***
- **Customize Transparency Mode\***
- **Multi-device connectivity\*** (upto 2 devices)
- **Other customizations**:
- Rename your AirPods
- Customize long-press actions
- All accessibility settings
- And more!
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple. - Add your AirPods to the Find My network
- Play sound through charging case to find it
- Notify when leaving behind
- Toggle case charging sounds
# Platform Support ## Spatial Audio
## Linux The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root.
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241)) Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28) ## Heart Rate Monitoring (AirPods Pro 3 and later)
This is being worked upon, check the #reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android.
## Android ## High quality two-way audio
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
### Screenshots Since this needs deeper integration with audio on Android, it will most likely need root.
| | | | # Installation
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
| ![Settings 1](./android/imgs/settings-1.png) | ![Settings 2](./android/imgs/settings-2.png) | ![Debug Screen](./android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](./android/imgs/notification-and-qs.png) | ![Popup](./android/imgs/popup.png) | ![Head Tracking and Gestures](./android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./android/imgs/long-press.png) | ![Widget](./android/imgs/widget.png) | ![Customizations 1](./android/imgs/customizations-1.png) |
| ![Customizations 2](./android/imgs/customizations-2.png) | ![accessibility](./android/imgs/accessibility.png) | ![transparency](./android/imgs/transparency.png) |
| ![hearing-aid](./android/imgs/hearing-aid.png) | ![hearing-test](./android/imgs/hearing-test.png) | ![hearing-aid-adjustments](./android/imgs/hearing-aid-adjustments.png) |
- [**Android**](/android/README.md)
- [**Linux**](/linux/README.md)
here's a very unprofessional demo video # VendorID Spoofing
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Root Requirement
The app 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.
[https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
Please do not comment in the thread. The issue has already been resolved and should be available in Android 17 for all devices.
However, if you are using ColorOS/OxygenOS 16, Android 16 QPR3 on Pixel (ensure you're on the latest Play system update), you don't need root for most features.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
Features requiring the VendorID hook will still require root. These features include customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint.
### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
# Changing VendorID in the DID profile to that of Apple
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features! Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings. You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled).
## Multi-device Connectivity ## Multi-device Connectivity
@@ -120,49 +105,123 @@ Accessibility settings like customizing transparency mode (amplification, balanc
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
# Protocol and Reverse Engineering
Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark)
The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts!
# Use of AI
## Android app
These parts of the app were completely AI-generated:
- Head Gestures - all of it, including logic and the UI
- The offset setup with r2+the xposed module (both versions)
- Troubleshooter and LogCollector
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
## Linux (rewrite)
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
# Supporters # Supporters
A huge thank you to everyone supporting the project! A huge thank you to everyone supporting the project!
- @davdroman
- @tedsalmon
- @wiless
- @SmartMsg
- @lunaroyster
- @ressiwage
# Special thanks <table>
<tr>
<td align="center">
<a href="https://github.com/davdroman">
<img src="https://github.com/davdroman.png?size=48" width="48" height="48"alt="davdroman"/><br />
@davdroman
</a>
</td>
<td align="center">
<a href="https://github.com/tedsalmon">
<img src="https://github.com/tedsalmon.png?size=48" width="48" height="48"alt="tedsalmon"/><br />
@tedsalmon
</a>
</td>
<td align="center">
<a href="https://github.com/wiless">
<img src="https://github.com/wiless.png?size=48" width="48" height="48"alt="wiless"/><br />
@wiless
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/SmartMsg">
<img src="https://github.com/SmartMsg.png?size=48" width="48" height="48"alt="SmartMsg"/><br />
@SmartMsg
</a>
</td>
<td align="center">
<a href="https://github.com/lunaroyster">
<img src="https://github.com/lunaroyster.png?size=48" width="48" height="48"alt="lunaroyster"/><br />
@lunaroyster
</a>
</td>
<td align="center">
<a href="https://github.com/ressiwage">
<img src="https://github.com/ressiwage.png?size=48" width="48" height="48"alt="ressiwage"/><br />
@ressiwage
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/kkjdroid">
<img src="https://github.com/kkjdroid.png?size=48" width="48" height="48"alt="kkjdroid"/><br />
@kkjdroid
</a>
</td>
<td align="center">
<a href="https://github.com/CitrusJoules">
<img src="https://github.com/CitrusJoules.png?size=48" width="48" height="48"alt="CitrusJoules"/><br />
@CitrusJoules
</a>
</td>
<td align="center">
<a href="https://github.com/DanielReyesDev">
<img src="https://github.com/DanielReyesDev.png?size=48" width="48" height="48"alt="DanielReyesDev"/><br />
@DanielReyesDev
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/sumitduster">
<img src="https://github.com/sumitduster.png?size=48" width="48" height="48"alt="sumitduster"/><br />
@sumitduster
</a>
</td>
<td align="center">
<a href="https://github.com/GrifTheDev">
<img src="https://github.com/GrifTheDev.png?size=48" width="48" height="48"alt="GrifTheDev"/><br />
@GrifTheDev
</a>
</td>
</tr>
</table>
# Special Thanks
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion)) - @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016)) - @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
- @devnoname120 for helping with the first root patch - @devnoname120 for helping with the first root patch
- @timgromeyer for making the first version of the linux app - @timgromeyer for making the first version of the linux app
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)! - @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest!
# Alternates for other platforms: # Alternates for other platforms:
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted. - CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/)) - MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/)) - MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
# Nightly/Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
### Android
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
2. Click on the most recent successful workflow run
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
4. Extract the zip and install the `.apk` on your device
> [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
2. Click on the most recent successful workflow run
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
> [!WARNING]
> Nightly builds are unstable and may contain bugs. Use at your own risk.
# Star History # Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left"> <a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
@@ -191,4 +250,12 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. 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.
+67
View File
@@ -0,0 +1,67 @@
## Root Requirement
LibrePods *may* require root depending on your device/OS and what features you want access to:
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
## Installation
### Google Play Store
If you are using a supported device/OS combination, you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
### GitHub Releases
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
### As a system app (root module)
If you want LibrePods to have privileged Bluetooth permissions to
- show battery status in the system settings and widgets
- show AirPods icon in the system settings (xposed is also currently required for this)
- switch audio to phone speakers when you are not wearing your AirPods
you can install the root module. This is optional and only provides extra features, but it is not required for the app to work.
> [!IMPORTANT]
> When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module.
## Nightly/Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly).
> [!WARNING]
> These builds are automatically generated from the latest code and may contain new features and bug fixes that haven't been included in a stable release yet. However, please note that they may also be less stable than official releases, so use them at your own risk.
## Screenshots
| | | |
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
| ![Settings 1](./imgs/settings-1.png) | ![Settings 2](./imgs/settings-2.png) | ![Head Tracking and Gestures](./imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./imgs/long-press.png) | ![Customizations 1](./imgs/customizations-1.png) | ![accessibility](./imgs/accessibility.png) |
| ![transparency](./imgs/transparency.png) | ![hearing-aid](./imgs/hearing-aid.png) | ![hearing-test](./imgs/hearing-test.png) |
| ![hearing-aid-adjustments](./imgs/hearing-aid-adjustments.png) | ![Battery Notification and QS Tile for NC Mode](./imgs/notification-and-qs.png) | ![Widget](./imgs/widget.png) |
here's a very unprofessional demo video
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
+66 -48
View File
@@ -1,6 +1,6 @@
import java.util.Properties import java.util.Properties
val versionName = "0.2.3" val appVersionName = "1.0.0-rc2"
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -10,12 +10,31 @@ plugins {
id("kotlin-parcelize") id("kotlin-parcelize")
} }
val localPropsFile = rootProject.file("local.properties")
val props = Properties().apply { val props = Properties().apply {
load(rootProject.file("local.properties").inputStream()) if (localPropsFile.exists()) {
load(localPropsFile.inputStream())
}
}
val releaseSigningAvailable = listOf(
"RELEASE_STORE_FILE",
"RELEASE_STORE_PASSWORD",
"RELEASE_KEY_ALIAS",
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
kotlin {
compilerOptions {
optIn.add(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
)
}
} }
android { android {
signingConfigs { signingConfigs {
if (releaseSigningAvailable) {
create("release") { create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String) storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String storePassword = props["RELEASE_STORE_PASSWORD"] as String
@@ -23,15 +42,15 @@ android {
keyPassword = props["RELEASE_KEY_PASSWORD"] as String keyPassword = props["RELEASE_KEY_PASSWORD"] as String
} }
} }
}
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 37 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33
targetSdk = 37 targetSdk = 37
versionCode = 38 versionCode = 63
versionName = versionName versionName = appVersionName
} }
buildTypes { buildTypes {
release { release {
@@ -45,23 +64,33 @@ android {
arguments += "-DCMAKE_BUILD_TYPE=Release" arguments += "-DCMAKE_BUILD_TYPE=Release"
} }
} }
buildConfigField("Boolean", "PLAY_BUILD", "false") if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
defaultConfig {
minSdk = 33
}
}
debug { debug {
buildConfigField("Boolean", "PLAY_BUILD", "false") if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-debug"
} }
create("playRelease") { versionNameSuffix = "-debug"
initWith(getByName("release")) defaultConfig {
minSdk = 33
}
}
}
productFlavors {
create("foss") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "false")
}
create("play") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play" versionNameSuffix = "-play"
} minSdk = 36
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-youshouldnothavethis"
} }
} }
compileOptions { compileOptions {
@@ -91,30 +120,12 @@ android {
ndkVersion = "30.0.14904198" ndkVersion = "30.0.14904198"
flavorDimensions += "env" flavorDimensions += "env"
productFlavors {
create("normal") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=OFF"
}
}
}
create("xposed") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=ON"
}
}
}
}
} }
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -139,9 +150,14 @@ dependencies {
implementation(libs.backdrop) implementation(libs.backdrop)
// implementation(libs.hilt) // implementation(libs.hilt)
// implementation(libs.hilt.compiler) // implementation(libs.hilt.compiler)
add("xposedCompileOnly", libs.libxposed.api) compileOnly(libs.libxposed.api)
add("xposedImplementation", libs.libxposed.service) implementation(libs.libxposed.service)
add("playReleaseImplementation", libs.billing) 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)
} }
aboutLibraries { aboutLibraries {
@@ -177,19 +193,21 @@ fun registerRootModuleZipTask(
rename { "LibrePods.apk" } rename { "LibrePods.apk" }
} }
archiveFileName.set("LibrePods-FOSS-v$versionName-$buildType.zip") delete(layout.buildDirectory.dir("outputs/rootModuleZips"))
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips")) destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
} }
val zipRelease = registerRootModuleZipTask( val zipRelease = registerRootModuleZipTask(
"zipXposedReleaseModule", "zipReleaseModule",
"xposed", "foss",
"release" "release"
) )
val zipDebug = registerRootModuleZipTask( val zipDebug = registerRootModuleZipTask(
"zipXposedDebugModule", "zipDebugModule",
"xposed", "foss",
"debug" "debug"
) )
@@ -198,22 +216,22 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn( dependsOn(
zipRelease, zipRelease,
zipDebug, zipDebug,
"bundleXposedPlayRelease" "bundlePlayRelease"
) )
into(releaseDir) into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) { from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
include("*.apk") include("*.apk")
rename(".*", "LibrePods-FOSS-v$versionName-release.apk") rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
} }
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) { from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
include("*.apk") include("*.apk")
rename(".*", "LibrePods-FOSS-v$versionName-debug.apk") rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
} }
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) { from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
include("*.aab") include("*.aab")
} }
+17 -23
View File
@@ -3,8 +3,6 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook") project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
option(IS_XPOSED "Build Xposed components" OFF)
add_library(bluetooth_socket SHARED add_library(bluetooth_socket SHARED
bluetooth_socket.cpp bluetooth_socket.cpp
) )
@@ -24,27 +22,25 @@ target_link_libraries(bluetooth_socket
log log
) )
if(IS_XPOSED)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp) set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED add_library(l2c_fcr_hook SHARED
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp l2c_fcr_hook.cpp
${XPOSED_SRC_DIR}/xz/xz_crc32.c xz/xz_crc32.c
${XPOSED_SRC_DIR}/xz/xz_crc64.c xz/xz_crc64.c
${XPOSED_SRC_DIR}/xz/xz_sha256.c xz/xz_sha256.c
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c xz/xz_dec_stream.c
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c xz/xz_dec_lzma2.c
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c xz/xz_dec_bcj.c
) )
target_include_directories(l2c_fcr_hook PRIVATE target_include_directories(l2c_fcr_hook PRIVATE
${XPOSED_SRC_DIR} xz
${XPOSED_SRC_DIR}/xz )
)
target_compile_definitions(l2c_fcr_hook PRIVATE target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86 XZ_DEC_X86
XZ_DEC_ARM XZ_DEC_ARM
XZ_DEC_ARMTHUMB XZ_DEC_ARMTHUMB
@@ -53,11 +49,9 @@ if(IS_XPOSED)
XZ_USE_CRC64 XZ_USE_CRC64
XZ_USE_SHA256 XZ_USE_SHA256
XZ_DEC_CONCATENATED XZ_DEC_CONCATENATED
) )
target_link_libraries(l2c_fcr_hook target_link_libraries(l2c_fcr_hook
android android
log log
) )
endif()
@@ -9,8 +9,10 @@ import io.github.libxposed.service.XposedServiceHelper
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.utils.XposedServiceHolder import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
override fun onCreate() { override fun onCreate() {
XposedServiceHelper.registerListener(this) XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this) BillingManager.provider = BillingProviderFactory.create(this)
@@ -22,13 +24,18 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
override fun onResume(owner: LifecycleOwner) { override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases() 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(p0: XposedService) { override fun onServiceBind(service: XposedService) {
XposedServiceHolder.service = p0 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) { override fun onServiceDied(p0: XposedService) {
XposedServiceHolder.service = null XposedServiceHolder.service = null
XposedState.isAvailable = false
} }
} }
@@ -24,140 +24,51 @@ package me.kavishdevar.librepods
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint //import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Build import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
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.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.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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext 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.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
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.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.android.play.core.review.ReviewManagerFactory
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.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.PlayBypassSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.isSupported import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint //@AndroidEntryPoint
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
init { init {
if (BuildConfig.FLAVOR == "xposed") { if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
System.loadLibrary("l2c_fcr_hook") System.loadLibrary("l2c_fcr_hook")
} }
} }
@@ -169,7 +80,29 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LibrePodsTheme { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) }
val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
"m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true)
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener)
}
}
LibrePodsTheme(
m3eEnabled = m3eEnabled.value
) {
// For demo screenshots
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
Main() Main()
} }
} }
@@ -216,671 +149,97 @@ class MainActivity : ComponentActivity() {
fun Main() { fun Main() {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) }
val showPlayBypassVisible = remember { mutableStateOf(false) }
val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Column (
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Spacer(modifier = Modifier.height(48.dp))
Column(
modifier = Modifier.layerBackdrop(innerBackdrop),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.not_supported),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = textColor,
fontSize = 20.sp,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
DeviceInfoCard()
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
)
}
}
StyledButton(
onClick = { showDialog.value = true },
backdrop = innerBackdrop,
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
)
}
Spacer(modifier = Modifier.height(48.dp))
}
}
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.bypass_compatibility_check),
message = stringResource(R.string.bypass_compatiblity_check_confirmation),
confirmText = stringResource(R.string.yes),
dismissText = stringResource(R.string.no),
onConfirm = {
showDialog.value = false
if (BuildConfig.PLAY_BUILD) {
showPlayBypassVisible.value = true
} else {
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
}
},
onDismiss = {
showDialog.value = false
},
hazeState = hazeState
)
if (BuildConfig.PLAY_BUILD) {
PlayBypassSheet(
visible = showPlayBypassVisible.value,
onDismiss = {
showPlayBypassVisible.value = false
showDialog.value = true
},
onConfirm = {
showPlayBypassVisible.value = false
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
backdrop = backdrop
)
}
return
}
val isConnected = remember { mutableStateOf(false) }
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember {
mutableStateOf(
context.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("overlay_permission_skipped", false)
)
}
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE"
)
} else {
listOf(
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.ACCESS_FINE_LOCATION"
)
}
val otherPermissions = listOf(
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
val allPermissions = bluetoothPermissions + otherPermissions
val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel = remember(airPodsService.value) { val airPodsViewModel: AirPodsViewModel = viewModel()
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
controlRepo = ControlCommandRepository(service.aacpManager),
appContext = context.applicationContext
)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context) if (BuildConfig.PLAY_BUILD) {
} val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val navController = rememberNavController() val oneDay = 24 * 60 * 60 * 1000L
Box( if (
modifier = Modifier.fillMaxSize() firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) { ) {
val backButtonBackdrop = rememberLayerBackdrop() triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
Box(
modifier = Modifier sharedPreferences.edit {
.fillMaxSize() putBoolean("review_prompted", true)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
.layerBackdrop(backButtonBackdrop)
) {
NavHost(
navController = navController,
startDestination = "settings",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it / 4 },
animationSpec = tween(durationMillis = 300)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
)
}) {
composable("settings") {
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
} }
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
if (airPodsViewModel != null) LongPress(
viewModel = airPodsViewModel,
name = navBackStackEntry.arguments?.getString("bud")!!,
navController = navController
)
}
composable("rename") {
if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
}
composable("app_settings") {
val appSettingsViewModel: AppSettingsViewModel = viewModel()
AppSettingsScreen(navController, appSettingsViewModel)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
}
composable("accessibility") {
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
}
composable("transparency_customization") {
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
}
composable("hearing_aid") {
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
}
composable("hearing_aid_adjustments") {
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
}
composable("adaptive_strength") {
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
}
composable("camera_control") {
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen()
}
composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
}
composable("hearing_protection") {
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
} }
} }
} }
val showBackButton = remember { mutableStateOf(false) } val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
LaunchedEffect(navController) { val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
navController.addOnDestinationChangedListener { _, destination, _ -> val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
showBackButton.value =
destination.route != "settings" // && destination.route != "onboarding"
}
}
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 = "􀯶",
backdrop = backButtonBackdrop
)
}
}
fun bindService() {
context.startForegroundService(Intent(context, AirPodsService::class.java)) context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = object: ServiceConnection {
serviceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService() val service = binder.getService()
airPodsService.value = service
airPodsViewModel.init(
service = service,
controlRepo = ControlCommandRepository(service.aacpManager),
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
appContext = context.applicationContext
)
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null airPodsService.value = null
} }
} }
}
context.bindService( context.bindService(
Intent(context, AirPodsService::class.java), Intent(context, AirPodsService::class.java),
serviceConnection, serviceConnection,
Context.BIND_AUTO_CREATE Context.BIND_AUTO_CREATE
) )
if (airPodsService.value?.isConnected() == 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)) if (onboardingComplete) {
bindService()
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)) NavigationRoot(
showReleaseNotes = !releaseNotesShown,
Button( updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
onClick = { showOnboarding = !onboardingComplete,
val intent = Intent( onboardingComplete = {
Settings.ACTION_MANAGE_OVERLAY_PERMISSION, sharedPreferences.edit { putBoolean("onboarding_complete", true) }
"package:${context.packageName}".toUri() bindService()
)
context.startActivity(intent)
onOverlaySettingsReturn()
}, },
modifier = Modifier airPodsViewModel = airPodsViewModel
.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) { private fun triggerReviewFlow(activity: Activity) {
Spacer(modifier = Modifier.height(12.dp)) val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
Button( request.addOnCompleteListener { task ->
onClick = { if (task.isSuccessful) {
context.getSharedPreferences("settings", MODE_PRIVATE).edit { val reviewInfo = task.result
putBoolean("overlay_permission_skipped", true) manager.launchReviewFlow(activity, reviewInfo)
}
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
)
)
}
} }
} }
} }
@@ -21,6 +21,8 @@
package me.kavishdevar.librepods.bluetooth package me.kavishdevar.librepods.bluetooth
import android.util.Log import android.util.Log
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.CustomEq
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
* constructing and parsing packets for communication with AirPods. * constructing and parsing packets for communication with AirPods.
*/ */
class AACPManager { class AACPManager {
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
companion object { companion object {
private const val TAG = "AACPManager"
@Suppress("unused") @Suppress("unused")
object Opcodes { object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4D const val SET_FEATURE_FLAGS: Byte = 0x4D
@@ -48,7 +49,7 @@ class AACPManager {
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19 const val STEM_PRESS: Byte = 0x19
const val EQ_DATA: Byte = 0x53 const val HEADPHONE_ACCOMMODATION: Byte = 0x53
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1 const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2 const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
const val SMART_ROUTING: Byte = 0x10 const val SMART_ROUTING: Byte = 0x10
@@ -56,6 +57,7 @@ class AACPManager {
const val SMART_ROUTING_RESP: Byte = 0x11 const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14 const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant? const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
const val CUSTOM_EQ: Byte = 0x63
} }
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -109,7 +111,8 @@ class AACPManager {
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG( EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
0x37 0x37
), ),
PPE_CAP_LEVEL_CONFIG(0x38); PPE_CAP_LEVEL_CONFIG(0x38),
DYNAMIC_END_OF_CHARGE(0x3B);
companion object { companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? = fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -199,6 +202,11 @@ class AACPManager {
var eqOnMedia: Boolean = false var eqOnMedia: Boolean = false
private set private set
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
private set
var customEqCallback: ((CustomEq) -> Unit)? = null
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier } return controlCommandStatusList.find { it.identifier == identifier }
} }
@@ -207,10 +215,7 @@ class AACPManager {
identifier: ControlCommandIdentifiers, value: ByteArray identifier: ControlCommandIdentifiers, value: ByteArray
) { ) {
val existingStatus = getControlCommandStatus(identifier) val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) { if (existingStatus?.value.contentEquals(value)) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus) controlCommandStatusList.remove(existingStatus)
} }
controlCommandListeners[identifier]?.forEach { listener -> controlCommandListeners[identifier]?.forEach { listener ->
@@ -238,7 +243,9 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>) fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String) fun onShowNearbyUI(sender: String)
fun onEQPacketReceived(eqData: FloatArray) fun onHeadphoneAccommodationReceived(eqData: FloatArray)
fun onCustomEqReceived(customEq: CustomEq)
fun onCapabilitiesReceived(capabilities: List<Capability>)
} }
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> { fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -414,7 +421,13 @@ class AACPManager {
} }
Opcodes.CONTROL_COMMAND -> { Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return
}
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value controlCommand.value
@@ -545,18 +558,18 @@ class AACPManager {
} }
} }
Opcodes.EQ_DATA -> { Opcodes.HEADPHONE_ACCOMMODATION -> {
if (packet.size != 140) { if (packet.size != 140) {
Log.w( Log.w(
TAG, TAG,
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140" "Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
) )
return return
} }
if (packet[6] != 0x84.toByte()) { if (packet[6] != 0x84.toByte()) {
Log.w( Log.w(
TAG, TAG,
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84" "Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
) )
return return
} }
@@ -579,7 +592,7 @@ class AACPManager {
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
) )
callback?.onEQPacketReceived(eqData) callback?.onHeadphoneAccommodationReceived(eqData)
} }
Opcodes.INFORMATION -> { Opcodes.INFORMATION -> {
@@ -588,6 +601,13 @@ class AACPManager {
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
Opcodes.CUSTOM_EQ -> {
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
customEq = parseCustomEqPacket(packet)
customEqCallback?.invoke(customEq)
callback?.onCustomEqReceived(customEq)
}
else -> { else -> {
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
@@ -1078,25 +1098,25 @@ class AACPManager {
companion object { companion object {
fun fromByteArray(data: ByteArray): ControlCommand { fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) { var offset = 0
throw IllegalArgumentException("Data array too short to parse ControlCommand") while (data.size - offset >= 4 &&
data[offset] == 0x04.toByte() &&
data[offset + 1] == 0x00.toByte() &&
data[offset + 2] == 0x04.toByte() &&
data[offset + 3] == 0x00.toByte()
) {
offset += 4
} }
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) { if (data.size - offset < 7) {
val newData = ByteArray(data.size - 4) throw IllegalArgumentException("Too short for ControlCommand")
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
} }
if (data[0] != Opcodes.CONTROL_COMMAND) { if (data[offset] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode") throw IllegalArgumentException("Invalid opcode")
} }
val identifier = data[2] val identifier = data[offset + 2]
val value = data.copyOfRange(offset + 3, offset + 7)
val value = ByteArray(4) val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
System.arraycopy(data, 3, value, 0, 4) return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
return ControlCommand(identifier, finalValue)
} }
} }
} }
@@ -1122,7 +1142,13 @@ class AACPManager {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) { if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Invalid control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return false
}
Log.d( Log.d(
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
@@ -1133,7 +1159,7 @@ class AACPManager {
) )
} }
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false val socket = BluetoothConnectionManager.aacpSocket ?: return false
if (socket.isConnected) { if (socket.isConnected) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
@@ -1287,4 +1313,38 @@ class AACPManager {
version3 = strings.getOrNull(10) ?: "", version3 = strings.getOrNull(10) ?: "",
) )
} }
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
return sendDataPacket(customEq.toPacket())
}
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
val data = packet.sliceArray(6 until packet.size)
if (data.size < 7) {
Log.e(TAG, "custom EQ packet length less than 7, returning default")
return CustomEq(1, 50, 50, 50)
}
val lengthLow = data[0].toInt() and 0xFF
val lengthHigh = data[1].toInt() and 0xFF
val length = (lengthHigh shl 8) or lengthLow
if (length != 5) {
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
}
val state = data[3].toInt()
val low = data[4].toInt()
val mid = data[5].toInt()
val high = data[6].toInt()
return CustomEq(
state,
low,
mid,
high
)
}
} }
@@ -16,233 +16,196 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
* and receiving notifications. It is not a complete implementation of the ATT protocol.
*/
package me.kavishdevar.librepods.bluetooth package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) { enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18), TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B), LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A), HEARING_AID(0x2A)
} }
enum class ATTCCCDHandles(val value: Int) { enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1), TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1), HEARING_AID(ATTHandles.HEARING_AID.value + 1)
} }
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) { class ATTManagerv2 {
companion object { val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private const val TAG = "ATTManager"
private const val OPCODE_READ_REQUEST: Byte = 0x0A private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
Log.e(TAG, "reader thread crashed: ${t.message}", t)
} finally {
readerRunning.set(false)
Log.d(TAG, "reader thread stopped")
}
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
Log.d(TAG, "reader started")
} }
var socket: BluetoothSocket? = null fun stopReader() {
private var input: InputStream? = null readerRunning.set(false)
private var output: OutputStream? = null readerThread?.interrupt()
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>() readerThread = null
private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
@SuppressLint("MissingPermission")
fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
try {
socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
} }
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.attSocket ?: return null
try { try {
socket!!.connect() val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect") Log.e(TAG, "error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
characteristicList[handle] = data
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
}
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.attSocket ?: return
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
return return
} }
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
notificationJob = CoroutineScope(Dispatchers.IO).launch { Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
while (socket?.isConnected == true) {
try {
val pdu = readPDU()
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(3, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}") Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
} else {
// not a notification -> treat as a response for pending request(s)
responses.put(pdu)
}
} catch (e: Exception) {
Log.w(TAG, "Error reading notification/response: ${e.message}")
if (socket?.isConnected != true) break
}
}
} }
} }
fun disconnect() { fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.attSocket?: return
try { try {
notificationJob?.cancel() socket.close()
socket?.close()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}") Log.w(TAG, "error closing socket: ${e.message}")
} }
Log.d(TAG, "ATT disconnected")
} }
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) { private fun runReaderLoop() {
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener) val socket = BluetoothConnectionManager.attSocket ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
} }
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) { val input = socket.inputStream
listeners[handle.value]?.remove(listener)
}
fun enableNotifications(handle: ATTHandles) {
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
}
fun read(handle: ATTHandles): ByteArray {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
// wait for response placed into responses queue by the reader coroutine
return readResponse()
}
fun write(handle: ATTHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
fun write(handle: ATTCCCDHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
private fun writeRaw(pdu: ByteArray) {
output?.write(pdu)
output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
// rename / specialize: read raw PDU directly from input stream (blocking)
private fun readPDU(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512) val buffer = ByteArray(512)
val len = inp.read(buffer)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) { if (len == -1) {
disconnect() Log.w(TAG, "ATT input stream ended")
throw IllegalStateException("End of stream reached") break
} }
val data = buffer.copyOfRange(0, len) val data = buffer.copyOfRange(0, len)
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}") if (data.isEmpty()) continue
return data
}
// wait for a response PDU produced by the background reader val opcode = data[0]
private fun readResponse(timeoutMs: Long = 2000): ByteArray { Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try { try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS) onNotificationReceived?.invoke(handle, value)
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms") } catch (t: Throwable) {
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}") Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
return resp.copyOfRange(1, resp.size) }
} catch (e: InterruptedException) { } else {
Thread.currentThread().interrupt() Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
throw IllegalStateException("Interrupted while waiting for ATT response", e)
} }
} }
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid),
arrayOf(type, true, true, device, 31, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("ATTManager", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}") Log.e(TAG, "error in reader loop: ${e.message}", e)
lastException = e break
} }
} }
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" readerRunning.set(false)
Log.e("ATTManager", errorMessage) }
throw lastException ?: IllegalStateException(errorMessage)
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
return try {
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
e.printStackTrace()
null
}
} }
} }
@@ -18,23 +18,59 @@
package me.kavishdevar.librepods.bluetooth package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log import android.util.Log
object BluetoothConnectionManager { object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager" var aacpSocket: BluetoothSocket? = null
var attSocket: BluetoothSocket? = null
private var currentSocket: BluetoothSocket? = null }
private var currentDevice: BluetoothDevice? = null
fun createBluetoothSocket(
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) { adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
currentSocket = socket ): BluetoothSocket {
currentDevice = device val type = 3 // L2CAP
Log.d(TAG, "Current connection set to device: ${device.address}") val constructorSpecs = listOf(
} arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
fun getCurrentSocket(): BluetoothSocket? { arrayOf(device, type, 1, true, true, psm, uuid),
return currentSocket arrayOf(type, 1, true, true, device, psm, uuid),
} arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<psm>", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes =
params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("createSocket<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
} }
@@ -0,0 +1,27 @@
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
enum class CustomEqBand { LOW, MID, HIGH }
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
fun isEnabled(): Boolean {
return state == 2
}
fun toPacket(): ByteArray {
return byteArrayOf(
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
0x05, 0x00, // length (LE)
0x01, state.toByte(),
low.toByte(), mid.toByte(), high.toByte()
)
}
init {
require(low in 0..100) { "low must be between 0 and 100, was $low" }
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
require(high in 0..100) { "high must be between 0 and 100, was $high" }
}
}
@@ -26,10 +26,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HearingAidUtils" private const val TAG = "HearingAidUtils"
@@ -138,15 +138,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
} }
fun sendHearingAidSettings( fun sendHearingAidSettings(
attManager: ATTManager, currentData: ByteArray,
hearingAidSettings: HearingAidSettings, hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?> debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
) { ) {
debounceJob.value?.cancel() debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch { debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100) delay(100.milliseconds)
try { try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) { if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings") Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData) sender(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
} }
@@ -22,8 +22,10 @@ import android.os.Parcelable
import android.util.Log import android.util.Log
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
// TODO: Remove everything but Battery-related stuff
enum class Enums(val value: ByteArray) { enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION), NOISE_CANCELLATION(byteArrayOf(0x0d)),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)), PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)), SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value), NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
@@ -81,12 +83,12 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA" const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED" const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
} }
class EarDetection { class EarDetection {
private val notificationBit = Capabilities.EAR_DETECTION private val notificationBit = 6.toByte()
private val notificationPrefix = Enums.PREFIX.value + notificationBit private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01) var status: List<Byte> = listOf(0x01, 0x01)
@@ -243,13 +245,6 @@ class AirPodsNotifications {
} }
} }
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val EAR_DETECTION = byteArrayOf(0x06)
}
}
fun isHeadTrackingData(data: ByteArray): Boolean { fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false if (data.size <= 60) return false
@@ -83,7 +83,8 @@ data class TransparencySettings(
} }
} }
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings { fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float val enabled = buffer.float
@@ -0,0 +1,9 @@
package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
data class UpdateItem(
val titleRes: Int,
val descriptionRes: Int,
val demoComposeable: @Composable () -> Unit
)
@@ -0,0 +1,35 @@
package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
val update1_0_0 = listOf(
UpdateItem(
titleRes = R.string.material3e,
descriptionRes = R.string.update_m3e_description,
demoComposeable = @Composable {
AirPodsSettingsScreenPreviewMaterial()
}
),
UpdateItem(
titleRes = R.string.equalizer,
descriptionRes = R.string.update_equalizer_description,
demoComposeable = @Composable {
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
EqualizerScreenPreviewApple()
}
DesignSystem.Material -> {
EqualizerScreenPreviewMaterial()
}
}
}
),
)
val updates = update1_0_0
@@ -0,0 +1,528 @@
package me.kavishdevar.librepods.presentation
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
object MaterialIcons {
val notifications: ImageVector
get() {
if (_notifications != null) {
return _notifications!!
}
_notifications =
ImageVector.Builder(
name = "notifications",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(4f, 19f)
verticalLineTo(17f)
horizontalLineTo(6f)
verticalLineTo(10f)
quadTo(6f, 7.93f, 7.25f, 6.31f)
reflectiveQuadTo(10.5f, 4.2f)
verticalLineTo(3.5f)
quadToRelative(0f, -0.63f, 0.44f, -1.06f)
reflectiveQuadTo(12f, 2f)
reflectiveQuadToRelative(1.06f, 0.44f)
reflectiveQuadTo(13.5f, 3.5f)
verticalLineTo(4.2f)
quadToRelative(2f, 0.5f, 3.25f, 2.11f)
reflectiveQuadTo(18f, 10f)
verticalLineToRelative(7f)
horizontalLineToRelative(2f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
close()
moveToRelative(8f, -7.5f)
close()
moveTo(12f, 22f)
quadToRelative(-0.82f, 0f, -1.41f, -0.59f)
reflectiveQuadTo(10f, 20f)
horizontalLineToRelative(4f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(12f, 22f)
close()
moveTo(8f, 17f)
horizontalLineToRelative(8f)
verticalLineTo(10f)
quadTo(16f, 8.35f, 14.83f, 7.18f)
reflectiveQuadTo(12f, 6f)
reflectiveQuadTo(9.18f, 7.18f)
reflectiveQuadTo(8f, 10f)
verticalLineToRelative(7f)
close()
}
}
.build()
return _notifications!!
}
private var _notifications: ImageVector? = null
val headset_off: ImageVector
get() {
if (_headset_off != null) {
return _headset_off!!
}
_headset_off =
ImageVector.Builder(
name = "headset_off",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(21f, 18.15f)
lineToRelative(-2f, -2f)
verticalLineTo(14f)
horizontalLineTo(16.85f)
lineToRelative(-2f, -2f)
horizontalLineTo(19f)
verticalLineTo(11f)
quadTo(19f, 8.05f, 16.95f, 6.02f)
reflectiveQuadTo(12f, 4f)
quadTo(10.9f, 4f, 9.91f, 4.31f)
reflectiveQuadTo(8.1f, 5.2f)
lineTo(6.65f, 3.8f)
quadTo(7.78f, 2.92f, 9.14f, 2.46f)
reflectiveQuadTo(12f, 2f)
quadToRelative(1.85f, 0f, 3.49f, 0.7f)
reflectiveQuadToRelative(2.86f, 1.93f)
reflectiveQuadToRelative(1.94f, 2.86f)
reflectiveQuadTo(21f, 11f)
verticalLineToRelative(7.15f)
close()
moveTo(12f, 23f)
verticalLineTo(21f)
horizontalLineToRelative(6.18f)
lineToRelative(-1f, -1f)
horizontalLineTo(15f)
verticalLineTo(17.83f)
lineTo(5.53f, 8.35f)
quadTo(5.3f, 8.95f, 5.15f, 9.64f)
reflectiveQuadTo(5f, 11f)
verticalLineToRelative(1f)
horizontalLineTo(9f)
verticalLineToRelative(8f)
horizontalLineTo(5f)
quadTo(4.18f, 20f, 3.59f, 19.41f)
reflectiveQuadTo(3f, 18f)
verticalLineTo(11f)
quadTo(3f, 9.88f, 3.26f, 8.82f)
reflectiveQuadToRelative(0.76f, -2f)
lineTo(0.68f, 3.5f)
lineTo(2.1f, 2.1f)
lineTo(21.88f, 21.9f)
verticalLineTo(23f)
horizontalLineTo(12f)
close()
moveTo(5f, 18f)
horizontalLineTo(7f)
verticalLineTo(14f)
horizontalLineTo(5f)
verticalLineToRelative(4f)
close()
moveTo(5f, 14f)
horizontalLineTo(7f)
horizontalLineTo(5f)
close()
moveToRelative(11.85f, 0f)
horizontalLineTo(19f)
horizontalLineTo(16.85f)
close()
}
}
.build()
return _headset_off!!
}
private var _headset_off: ImageVector? = null
val pause: ImageVector
get() {
if (_pause != null) {
return _pause!!
}
_pause =
ImageVector.Builder(
name = "pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(13f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(13f)
close()
moveTo(5f, 19f)
verticalLineTo(5f)
horizontalLineToRelative(6f)
verticalLineTo(19f)
horizontalLineTo(5f)
close()
moveTo(15f, 17f)
horizontalLineToRelative(2f)
verticalLineTo(7f)
horizontalLineTo(15f)
verticalLineTo(17f)
close()
moveTo(7f, 17f)
horizontalLineTo(9f)
verticalLineTo(7f)
horizontalLineTo(7f)
verticalLineTo(17f)
close()
moveTo(7f, 7f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
moveToRelative(8f, 0f)
verticalLineTo(17f)
verticalLineTo(7f)
close()
}
}
.build()
return _pause!!
}
private var _pause: ImageVector? = null
val bluetooth: ImageVector
get() {
if (_bluetooth != null) {
return _bluetooth!!
}
_bluetooth =
ImageVector.Builder(
name = "bluetooth",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(11f, 22f)
verticalLineTo(14.4f)
lineTo(6.4f, 19f)
lineTo(5f, 17.6f)
lineTo(10.6f, 12f)
lineTo(5f, 6.4f)
lineTo(6.4f, 5f)
lineTo(11f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(13.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(12f, 22f)
horizontalLineTo(11f)
close()
moveTo(13f, 9.6f)
lineTo(14.9f, 7.7f)
lineTo(13f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(14.9f, 16.3f)
lineTo(13f, 14.4f)
verticalLineToRelative(3.75f)
close()
}
}
.build()
return _bluetooth!!
}
private var _bluetooth: ImageVector? = null
val bluetooth_searching: ImageVector
get() {
if (_bluetooth_searching != null) {
return _bluetooth_searching!!
}
_bluetooth_searching =
ImageVector.Builder(
name = "bluetooth_searching",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.NonZero,
) {
moveTo(9f, 22f)
verticalLineTo(14.4f)
lineTo(4.4f, 19f)
lineTo(3f, 17.6f)
lineTo(8.6f, 12f)
lineTo(3f, 6.4f)
lineTo(4.4f, 5f)
lineTo(9f, 9.6f)
verticalLineTo(2f)
horizontalLineToRelative(1f)
lineToRelative(5.7f, 5.7f)
lineTo(11.4f, 12f)
lineToRelative(4.3f, 4.3f)
lineTo(10f, 22f)
horizontalLineTo(9f)
close()
moveTo(11f, 9.6f)
lineTo(12.9f, 7.7f)
lineTo(11f, 5.85f)
verticalLineTo(9.6f)
close()
moveToRelative(0f, 8.55f)
lineTo(12.9f, 16.3f)
lineTo(11f, 14.4f)
verticalLineToRelative(3.75f)
close()
moveToRelative(5.55f, -3.8f)
lineTo(14.25f, 12f)
lineToRelative(2.3f, -2.3f)
quadToRelative(0.23f, 0.55f, 0.36f, 1.13f)
reflectiveQuadTo(17.05f, 12f)
reflectiveQuadToRelative(-0.14f, 1.19f)
quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f)
close()
moveTo(19.5f, 17.2f)
lineTo(18.25f, 16f)
quadToRelative(0.5f, -0.93f, 0.78f, -1.94f)
reflectiveQuadTo(19.3f, 12f)
reflectiveQuadTo(19.03f, 9.94f)
quadTo(18.75f, 8.92f, 18.25f, 8f)
lineTo(19.5f, 6.75f)
quadToRelative(0.73f, 1.2f, 1.11f, 2.52f)
reflectiveQuadTo(21f, 12f)
reflectiveQuadToRelative(-0.39f, 2.71f)
quadTo(20.23f, 16.02f, 19.5f, 17.2f)
close()
}
}
.build()
return _bluetooth_searching!!
}
private var _bluetooth_searching: ImageVector? = null
val call: ImageVector
get() {
if (_call != null) {
return _call!!
}
_call =
ImageVector.Builder(
name = "call",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(19.95f, 21f)
quadToRelative(-3.13f, 0f, -6.18f, -1.36f)
reflectiveQuadTo(8.23f, 15.78f)
quadTo(5.73f, 13.27f, 4.36f, 10.23f)
reflectiveQuadTo(3f, 4.05f)
quadTo(3f, 3.6f, 3.3f, 3.3f)
reflectiveQuadTo(4.05f, 3f)
horizontalLineTo(8.1f)
quadTo(8.45f, 3f, 8.73f, 3.24f)
reflectiveQuadTo(9.05f, 3.8f)
lineTo(9.7f, 7.3f)
quadTo(9.75f, 7.7f, 9.68f, 7.97f)
reflectiveQuadTo(9.4f, 8.45f)
lineTo(6.98f, 10.9f)
quadToRelative(0.5f, 0.93f, 1.19f, 1.79f)
reflectiveQuadToRelative(1.51f, 1.66f)
quadToRelative(0.78f, 0.78f, 1.63f, 1.44f)
reflectiveQuadTo(13.1f, 17f)
lineToRelative(2.35f, -2.35f)
quadToRelative(0.22f, -0.23f, 0.59f, -0.34f)
reflectiveQuadToRelative(0.71f, -0.06f)
lineToRelative(3.45f, 0.7f)
quadToRelative(0.35f, 0.1f, 0.57f, 0.36f)
reflectiveQuadTo(21f, 15.9f)
verticalLineToRelative(4.05f)
quadToRelative(0f, 0.45f, -0.3f, 0.75f)
reflectiveQuadTo(19.95f, 21f)
close()
moveTo(6.03f, 9f)
lineTo(7.68f, 7.35f)
lineTo(7.25f, 5f)
horizontalLineTo(5.03f)
quadTo(5.15f, 6.02f, 5.38f, 7.02f)
reflectiveQuadTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
quadToRelative(0.97f, 0.43f, 1.99f, 0.68f)
reflectiveQuadTo(19f, 18.95f)
verticalLineToRelative(-2.2f)
lineTo(16.65f, 16.27f)
lineToRelative(-1.68f, 1.68f)
close()
moveTo(6.03f, 9f)
close()
moveToRelative(8.95f, 8.95f)
close()
}
}
.build()
return _call!!
}
private var _call: ImageVector? = null
val stack: ImageVector
get() {
if (_stack != null) {
return _stack!!
}
_stack =
ImageVector.Builder(
name = "stack",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
)
.apply {
path(
fill = SolidColor(Color.Black),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Bevel,
strokeLineMiter = 1f,
pathFillType = PathFillType.Companion.NonZero,
) {
moveTo(6f, 14f)
verticalLineToRelative(2f)
horizontalLineTo(4f)
quadTo(3.18f, 16f, 2.59f, 15.41f)
reflectiveQuadTo(2f, 14f)
verticalLineTo(4f)
quadTo(2f, 3.17f, 2.59f, 2.59f)
reflectiveQuadTo(4f, 2f)
horizontalLineTo(14f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(16f, 4f)
verticalLineTo(6f)
horizontalLineTo(14f)
verticalLineTo(4f)
horizontalLineTo(4f)
verticalLineTo(14f)
horizontalLineTo(6f)
close()
moveToRelative(4f, 8f)
quadTo(9.18f, 22f, 8.59f, 21.41f)
reflectiveQuadTo(8f, 20f)
verticalLineTo(10f)
quadTo(8f, 9.17f, 8.59f, 8.59f)
reflectiveQuadTo(10f, 8f)
horizontalLineTo(20f)
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
reflectiveQuadTo(22f, 10f)
verticalLineTo(20f)
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
reflectiveQuadTo(20f, 22f)
horizontalLineTo(10f)
close()
moveToRelative(0f, -2f)
horizontalLineTo(20f)
verticalLineTo(10f)
horizontalLineTo(10f)
verticalLineTo(20f)
close()
moveToRelative(5f, -5f)
close()
}
}
.build()
return _stack!!
}
private var _stack: ImageVector? = null
}
@@ -20,187 +20,60 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
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.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.Composable
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AboutCard( fun AboutCard(
navController: NavController,
modelName: String, modelName: String,
actualModel: String, actualModel: String,
serialNumbers: List<String>, serialNumbers: List<String>,
version: String? version: String?,
navigateToVersion: () -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val serialNumbers = when (LocalDesignSystem.current) {
val textColor = if (isDarkTheme) Color.White else Color.Black DesignSystem.Apple -> listOf(
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),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
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 = modelName,
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 = actualModel,
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(
serialNumbers[0], serialNumbers[0],
"􀀛 ${serialNumbers[1]}", "􀀛 ${serialNumbers[1]}",
"􀀧 ${serialNumbers[2]}" "􀀧 ${serialNumbers[2]}"
) )
DesignSystem.Material -> listOf(
serialNumbers[0],
stringResource(R.string.left) + " " + serialNumbers[1],
stringResource(R.string.right) + " " + serialNumbers[2],
)
}
val serialNumber = remember { mutableIntStateOf(0) } val serialNumber = remember { mutableIntStateOf(0) }
Row(
modifier = Modifier StyledList (title = stringResource(R.string.about)) {
.fillMaxWidth() StyledListItem(
.padding(16.dp), name = stringResource(R.string.model_name),
horizontalArrangement = Arrangement.SpaceBetween, description = modelName
) {
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.intValue], StyledListItem(
style = TextStyle( name = stringResource(R.string.model_number),
fontSize = 16.sp, description = actualModel
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.intValue = (serialNumber.intValue + 1) % serialNumbers.size
}
) )
}
HorizontalDivider( StyledListItem (
thickness = 1.dp, name = stringResource(R.string.serial_number),
color = Color(0x40888888), description = serialNumbers[serialNumber.intValue],
modifier = Modifier onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
.padding(horizontal = 12.dp)
) )
NavigationButton(
to = "version_info", StyledListItem(
navController = navController,
name = stringResource(R.string.version), name = stringResource(R.string.version),
currentState = version, description = version,
independent = false, onClick = navigateToVersion,
height = rowHeight.value + 32.dp
) )
} }
} }
@@ -0,0 +1,52 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@Composable
fun AppInfoCard(
navigateToReleaseNotesScreen: (() -> Unit)? = null,
) {
StyledList(title = stringResource(R.string.about)) {
StyledListItem(
name = stringResource(R.string.version),
description = BuildConfig.VERSION_NAME,
onClick = navigateToReleaseNotesScreen
)
StyledListItem(
name = stringResource(R.string.version_code),
description = BuildConfig.VERSION_CODE.toString(),
)
StyledListItem(
name = stringResource(R.string.flavor),
description = BuildConfig.FLAVOR,
)
StyledListItem(
name = stringResource(R.string.build_type),
description = BuildConfig.BUILD_TYPE,
)
}
}
@@ -20,39 +20,18 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.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.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 androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings( fun AudioSettings(
navController: NavController,
adaptiveVolumeCapability: Boolean, adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean, conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean, loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean, adaptiveAudioCapability: Boolean,
customEqCapability: Boolean,
adaptiveVolumeChecked: Boolean, adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit, onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
@@ -63,56 +42,21 @@ fun AudioSettings(
loudSoundReductionChecked: Boolean, loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit, onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
navigateToAdaptiveStrength: () -> Unit,
navigateToEqualizer: () -> Unit,
vendorIdHook: Boolean, vendorIdHook: Boolean,
isPremium: Boolean isPremium: Boolean
) { ) {
val isDarkTheme = isSystemInDarkTheme() if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
val textColor = if (isDarkTheme) Color.White else Color.Black StyledList(title = stringResource(R.string.audio)) {
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
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),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
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 (adaptiveVolumeCapability) { if (adaptiveVolumeCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
independent = false,
checked = adaptiveVolumeChecked, checked = adaptiveVolumeChecked,
onCheckedChange = onAdaptiveVolumeCheckedChange, onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium enabled = isPremium,
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
) )
} }
@@ -120,63 +64,35 @@ fun AudioSettings(
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
independent = false,
checked = conversationalAwarenessChecked, checked = conversationalAwarenessChecked,
onCheckedChange = onConversationalAwarenessCheckedChange, onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium enabled = isPremium,
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
) )
} }
if (loudSoundReductionCapability && vendorIdHook){ if (loudSoundReductionCapability && vendorIdHook) {
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
independent = false,
checked = loudSoundReductionChecked, checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange, onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium enabled = isPremium,
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
) )
} }
if (adaptiveAudioCapability) { if (adaptiveAudioCapability) {
NavigationButton( StyledListItem(
to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
navController = navController, onClick = navigateToAdaptiveStrength,
independent = false
) )
} }
}
}
@Preview if (customEqCapability) {
@Composable StyledListItem(
fun AudioSettingsPreview() { name = stringResource(R.string.equalizer),
AudioSettings( onClick = navigateToEqualizer,
navController = rememberNavController(),
adaptiveVolumeCapability = true,
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
vendorIdHook = true,
isPremium = true
) )
}
}
}
} }
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -53,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.min import kotlin.math.min
import kotlin.math.sin import kotlin.math.sin
@@ -66,7 +68,6 @@ fun BatteryIndicator(
previousCharging: Boolean = false, previousCharging: Boolean = false,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759) if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
@@ -82,7 +83,7 @@ fun BatteryIndicator(
} }
Column( Column(
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
@@ -200,10 +201,7 @@ fun BatteryIndicator(
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BatteryIndicatorPreview() { fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) LibrePodsTheme(m3eEnabled = false) {
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator( BatteryIndicator(
batteryPercentage = 50, batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING, status = BatteryStatus.OPTIMIZED_CHARGING,
@@ -64,9 +64,6 @@ fun BatteryView(
val rightLevel = right?.level ?: 0 val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0 val caseLevel = case?.level ?: 0
val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) } val singleDisplayed = remember { mutableStateOf(false) }
Box( Box(
@@ -20,413 +20,70 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.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 dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings( fun CallControlSettings(
hazeState: HazeState,
flipped: Boolean, flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit navigateToCallControlScreen: (action: String) -> Unit,
) { ) {
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),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val pressOnceText = stringResource(R.string.press_once) val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice) val pressTwiceText = stringResource(R.string.press_twice)
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
var showSinglePressDropdown by remember { mutableStateOf(false) } val muteUnmuteText = stringResource(R.string.mute_unmute)
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) } val hangUpText = stringResource(R.string.hang_up)
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 previousIdxSingle by remember { mutableStateOf<Int?>(null) }
var showDoublePressDropdown by remember { mutableStateOf(false) } StyledList(title = stringResource(R.string.call_controls)) {
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) } StyledListItem(
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) } name = stringResource(R.string.answer_call),
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } description = stringResource(R.string.press_once),
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } enabled = false
var parentDragActiveDouble by remember { mutableStateOf(false) }
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
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( StyledListItem(
modifier = Modifier name = muteUnmuteText,
.fillMaxWidth() description = singlePressAction,
.padding(horizontal = 16.dp) onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
.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()
if (idx != previousIdxSingle) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexSingle = idx
previousIdxSingle = 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()
onCallControlValueChanged(option != pressOnceText)
}
}
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
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 flipped = option != pressOnceText
onCallControlValueChanged(flipped)
},
hazeState = hazeState
)
}
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
) )
Row( StyledListItem(
modifier = Modifier name = hangUpText,
.fillMaxWidth() description = doublePressAction,
.padding(horizontal = 16.dp) onClick = { navigateToCallControlScreen(hangUpText) }
.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()
if (idx != previousIdxDouble) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexDouble = idx
previousIdxDouble = 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 flipped = option == pressOnceText
onCallControlValueChanged(flipped)
}
}
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
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( // StyledListItem(
expanded = showDoublePressDropdown, // name = pressOnceText,
onDismissRequest = { // selected = doublePressAction == pressOnceText,
showDoublePressDropdown = false // onClick = {
lastDismissTimeDouble = System.currentTimeMillis() // doublePressAction = pressOnceText
}, // singlePressAction = pressTwiceText
options = listOf(pressOnceText, pressTwiceText), //
selectedOption = doublePressAction, // onCallControlValueChanged(true)
touchOffset = touchOffsetDouble, // }
boxPosition = boxPositionDouble, // )
externalHoveredIndex = parentHoveredIndexDouble, //
externalDragActive = parentDragActiveDouble, // StyledListItem(
onOptionSelected = { option -> // name = pressTwiceText,
doublePressAction = option // selected = doublePressAction == pressTwiceText,
singlePressAction = // onClick = {
if (option == pressOnceText) pressTwiceText else pressOnceText // doublePressAction = pressTwiceText
showDoublePressDropdown = false // singlePressAction = pressOnceText
val flipped = option == pressOnceText //
onCallControlValueChanged(flipped) // onCallControlValueChanged(false)
}, // }
hazeState = hazeState // )
)
}
}
}
} }
} }
@@ -18,37 +18,36 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -56,15 +55,19 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import dev.chrisbanes.haze.HazeState import com.kyant.backdrop.backdrops.LayerBackdrop
import dev.chrisbanes.haze.hazeEffect import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.CupertinoMaterials import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun ConfirmationDialog( fun ConfirmationDialog(
@@ -75,163 +78,139 @@ fun ConfirmationDialog(
dismissText: String = "Cancel", dismissText: String = "Cancel",
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false }, onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState, backdrop: LayerBackdrop,
) { ) {
val isDarkTheme = isSystemInDarkTheme() AnimatedVisibility(
val textColor = if (isDarkTheme) Color.White else Color.Black visible = showDialog.value,
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut()
val haptics = LocalHapticFeedback.current ) {
val scope = rememberCoroutineScope() when (LocalDesignSystem.current) {
DesignSystem.Material -> {
if (showDialog.value) { BasicAlertDialog(
Dialog( onDismissRequest = onDismiss,
onDismissRequest = { showDialog.value = false },
properties = DialogProperties( properties = DialogProperties(
dismissOnBackPress = false, dismissOnBackPress = true,
dismissOnClickOutside = false dismissOnClickOutside = false
) )
) { ) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMediumEmphasized
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(
onClick = onDismiss
) {
Text(
text = dismissText,
style = MaterialTheme.typography.labelMedium
)
}
TextButton(
onClick = onConfirm
) {
Text(
text = confirmText,
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
DesignSystem.Apple -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
val isDarkTheme = isSystemInDarkTheme()
Box( Box(
modifier = Modifier modifier = Modifier
// .fillMaxWidth(0.75f)
.requiredWidthIn(min = 200.dp, max = 360.dp) .requiredWidthIn(min = 200.dp, max = 360.dp)
.background(Color.Transparent, RoundedCornerShape(14.dp)) .clip(RoundedCornerShape(48.dp))
.clip(RoundedCornerShape(14.dp)) .drawBackdrop(
.hazeEffect( backdrop = backdrop,
hazeState, exportedBackdrop = innerBackdrop,
style = CupertinoMaterials.regular( shape = { RoundedCornerShape(48.dp) },
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f) effects = {
) vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(
0xFFE0E0E0
).copy(alpha = 0.7f)
) )
})
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
title, title,
style = TextStyle( style = MaterialTheme.typography.bodyLarge,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
message, message,
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.fillMaxWidth()
)
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(0.9f),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(24.dp)
.height(48.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val position = event.changes.first().position
val width = size.width.toFloat()
val height = size.height.toFloat()
val isWithinBounds = position.y >= 0 && position.y <= height
val isLeft = position.x < width / 2
event.changes.first().consume()
when (event.type) {
PointerEventType.Press -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Move -> {
if (isWithinBounds) {
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Reject) }
onDismiss()
} else if (rightPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Confirm) }
onConfirm()
}
}
leftPressed = false
rightPressed = false
}
}
}
}
},
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) { ) {
Box( StyledButton(
modifier = Modifier onClick = onDismiss,
.weight(1f) backdrop = innerBackdrop,
.fillMaxHeight() modifier = Modifier.weight(1f),
.background(if (leftPressed) pressedColor else Color.Transparent), materialButtonStyle = MaterialButtonStyle.Outlined,
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = dismissText, text = dismissText,
style = TextStyle( style = MaterialTheme.typography.bodyMedium
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
) )
} }
Box( StyledButton(
modifier = Modifier onClick = onConfirm,
.width(1.dp) backdrop = innerBackdrop,
.fillMaxHeight() modifier = Modifier.weight(1f),
.background(Color(0x40888888)) materialButtonStyle = MaterialButtonStyle.Filled,
) surfaceColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = confirmText, text = confirmText,
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
color = accentColor, color = MaterialTheme.colorScheme.onPrimary
fontFamily = FontFamily(Font(R.font.sf_pro))
)
) )
} }
} }
Spacer(modifier = Modifier.height(24.dp))
}
}
}
} }
} }
} }
@@ -20,18 +20,8 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -42,32 +32,16 @@ fun ConnectionSettings(
automaticConnectionEnabled: Boolean, automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit, onAutomaticConnectionChanged: (Boolean) -> Unit,
) { ) {
val isDarkTheme = isSystemInDarkTheme() StyledList {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
StyledToggle( StyledToggle(
label = stringResource(R.string.ear_detection), label = stringResource(R.string.ear_detection),
independent = false,
checked = automaticEarDetectionEnabled, checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged onCheckedChange = onAutomaticEarDetectionChanged
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.automatically_connect), label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description), description = stringResource(R.string.automatically_connect_description),
independent = false,
checked = automaticConnectionEnabled, checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged onCheckedChange = onAutomaticConnectionChanged
) )
@@ -1,171 +1,56 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import android.os.Build import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable @Composable
fun DeviceInfoCard() { fun DeviceInfoCard() {
val isDarkTheme = isSystemInDarkTheme() StyledList(title = stringResource(R.string.device_info)) {
StyledListItem(
val textColor = if (isDarkTheme) Color.White else Color.Black name = stringResource(R.string.manufacturer),
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) description = Build.MANUFACTURER,
enabled = false
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.device_info), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 16.dp)
) )
Column(
modifier = Modifier StyledListItem(
.clip(RoundedCornerShape(28.dp)) name = stringResource(R.string.model_number),
.fillMaxWidth() description = Build.MODEL,
.background(backgroundColor, RoundedCornerShape(28.dp)) enabled = false
) { )
Row(
modifier = Modifier StyledListItem(
.fillMaxWidth() name = stringResource(R.string.build_id),
.padding(16.dp) description = Build.DISPLAY,
.onGloballyPositioned { coordinates -> enabled = false
rowHeight.value = with(density) { coordinates.size.height.toDp() } )
StyledListItem(
name = stringResource(R.string.version),
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
enabled = false
)
StyledListItem(
name = stringResource(R.string.xposed_available),
description = if (XposedState.isAvailable) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
}, },
horizontalArrangement = Arrangement.SpaceBetween, enabled = false
) {
Text(
text = stringResource(R.string.manufacturer), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
StyledListItem(
name = stringResource(R.string.app_enabled_in_xposed),
description = if (XposedState.bluetoothScopeEnabled) {
stringResource(R.string.yes)
} else {
stringResource(R.string.no)
},
enabled = false
) )
Text(
text = Build.MANUFACTURER, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_number), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MODEL, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_id), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.DISPLAY, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
} }
} }
@@ -20,99 +20,43 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun HearingHealthSettings( fun HearingHealthSettings(
navController: NavController,
hasPPECapability: Boolean, hasPPECapability: Boolean,
hasHearingAidCapability: Boolean, hasHearingAidCapability: Boolean,
vendorIdHook: Boolean vendorIdHook: Boolean,
navigateToHearingProtection: () -> Unit,
navigateToHearingAid: () -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) { if (hasPPECapability && shouldShowHearingAid) {
Box( StyledList(title = stringResource(R.string.hearing_health)) {
modifier = Modifier StyledListItem(
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection), name = stringResource(R.string.hearing_protection),
navController = navController, onClick = navigateToHearingProtection
independent = false
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid), name = stringResource(R.string.hearing_aid),
navController = navController, onClick = navigateToHearingAid
independent = false
) )
} }
} else if (shouldShowHearingAid) { } else if (shouldShowHearingAid) {
NavigationButton( StyledListItem(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid), name = stringResource(R.string.hearing_aid),
navController = navController onClick = navigateToHearingAid
) )
} else if (hasPPECapability) { } else if (hasPPECapability) {
NavigationButton( StyledListItem(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
title = stringResource(R.string.hearing_health), title = stringResource(R.string.hearing_health),
navController = navController name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
) )
} }
} }
@@ -1,263 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.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.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.platform.LocalHapticFeedback
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.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
fun MicrophoneSettings(
hazeState: HazeState,
micModeValue: Byte,
onMicModeValueChanged: (Byte) -> Unit
) {
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)
) {
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 density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val 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()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx
previousIdx = 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())
// )
onMicModeValueChanged(byteValue.toByte())
}
}
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
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
}
onMicModeValueChanged(byteValue.toByte())
},
hazeState = hazeState
)
}
}
}
}
@@ -1,179 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.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.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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import kotlinx.coroutines.launch
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,
enabled: Boolean = true
) {
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))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
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),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Row(
modifier = Modifier
.background(
animatedBackgroundColor,
RoundedCornerShape(if (independent) 28.dp else 0.dp)
)
.height(height)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
},
onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
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))
}
@@ -30,6 +30,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -40,10 +41,16 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,6 +58,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -59,19 +67,22 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
@@ -79,14 +90,109 @@ fun NoiseControlSettings(
noiseControlModeValue: Int, noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit onNoiseControlModeChanged: (Int) -> Unit
) { ) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
val options = buildList {
if (showOffListeningMode) {
add(
Triple(
NoiseControlMode.OFF,
R.string.off,
R.drawable.noise_cancellation
)
)
}
add(
Triple(
NoiseControlMode.TRANSPARENCY,
R.string.transparency,
R.drawable.transparency
)
)
add(
Triple(
NoiseControlMode.ADAPTIVE,
R.string.adaptive,
R.drawable.adaptive
)
)
add(
Triple(
NoiseControlMode.NOISE_CANCELLATION,
R.string.noise_cancellation,
R.drawable.noise_cancellation
)
)
}
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
Column {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
) {
Text(
text = stringResource(R.string.noise_control),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
) {
options.forEachIndexed { index, (mode, labelRes, iconRes) ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
ToggleButton(
checked = selectedMode == mode,
onCheckedChange = {
if (it) {
onNoiseControlModeChanged(mode.ordinal + 1)
}
},
shapes = when (index) {
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors = ToggleButtonDefaults.toggleButtonColors()
.copy(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Icon(
bitmap = ImageBitmap.imageResource(iconRes),
contentDescription = null,
modifier = Modifier.size(42.dp)
)
}
Text(
text = stringResource(labelRes),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
DesignSystem.Apple -> {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) } val d1a = remember { mutableFloatStateOf(0f) }
@@ -140,20 +246,16 @@ fun NoiseControlSettings(
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp)
){ .padding(top = 4.dp, bottom = 4.dp)
) {
Text( Text(
text = stringResource(R.string.noise_control), text = stringResource(R.string.noise_control),
style = TextStyle( color = MaterialTheme.colorScheme.sectionHeader,
fontSize = 14.sp, style = MaterialTheme.typography.labelSmallEmphasized
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
) )
} }
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -280,9 +382,10 @@ fun NoiseControlSettings(
onDragStarted = { isDragging.value = true }, onDragStarted = { isDragging.value = true },
onDragStopped = { onDragStopped = {
isDragging.value = false isDragging.value = false
val position = dragOffset / with(density) { buttonWidth.toPx() } val position =
dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt() val newIndex = position.roundToInt()
val newMode = when(newIndex) { val newMode = when (newIndex) {
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
@@ -394,4 +497,25 @@ fun NoiseControlSettings(
} }
} }
} }
}
}
}
@Preview
@Composable
fun NoiseControlSettingsPreview() {
LibrePodsTheme(
m3eEnabled = true
) {
Box(
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
NoiseControlSettings(
showOffListeningMode = false,
noiseControlModeValue = 2,
onNoiseControlModeChanged = { }
)
}
}
} }
@@ -1,254 +0,0 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.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.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayBypassSheet(
visible: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
backdrop: LayerBackdrop
) {
if (!visible) return
val dark = isSystemInDarkTheme()
val contentColor = if (dark) Color.White else Color.Black
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var acknowledged by remember { mutableStateOf(false) }
val inputState = rememberTextFieldState("")
val isValid = acknowledged && inputState.text.trim() == "OK"
val sfPro = FontFamily(Font(R.font.sf_pro))
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(48.dp),
scrimColor = Color.Transparent,
modifier = Modifier.padding(16.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(48.dp))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(48.dp) },
effects = {
vibrancy()
blur(6f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (dark) Color.DarkGray.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.6f)
)
}
)
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
Text(
text = stringResource(R.string.compatibility_play_dialog_confirmation),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSelectList(
items = listOf(
SelectItem(
name = stringResource(R.string.read_compatibility_requirements),
selected = acknowledged,
onClick = { acknowledged = !acknowledged }
)
)
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
val backgroundColor = if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (dark) 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(
state = inputState,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box {
if (inputState.text == "") {
Text(
text = stringResource(R.string.type_ok_to_continue, "OK"),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = sfPro,
color = textColor.copy(alpha = 0.8f)
)
)
}
innerTextField()
}
}
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (dark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
),
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.no),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
)
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
isInteractive = isValid,
modifier = Modifier.weight(1f),
enabled = isValid,
surfaceColor = if (dark) Color(0xFF0091FF) else Color(0xFF0088FF)
) {
Text(
text = stringResource(R.string.proceed),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
)
)
}
}
}
}
}
}
@@ -18,40 +18,18 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
@Composable @Composable
fun PressAndHoldSettings( fun PressAndHoldSettings(
navController: NavController,
leftAction: StemAction, leftAction: StemAction,
rightAction: StemAction rightAction: StemAction,
navigateToLeftLongPress: () -> Unit,
navigateToRightLongPress: () -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888)
val leftActionText = when (leftAction) { val leftActionText = when (leftAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
@@ -63,46 +41,19 @@ fun PressAndHoldSettings(
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
Box(
modifier = Modifier StyledList(
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) title = stringResource(R.string.press_and_hold_airpods)
.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( StyledListItem(
to = "long_press/Left",
name = stringResource(R.string.left), name = stringResource(R.string.left),
navController = navController, description = leftActionText,
independent = false, onClick = navigateToLeftLongPress
currentState = leftActionText,
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = dividerColor,
modifier = Modifier
.padding(horizontal = 16.dp)
)
NavigationButton(
to = "long_press/Right",
name = stringResource(R.string.right), name = stringResource(R.string.right),
navController = navController, description = rightActionText,
independent = false, onClick = navigateToRightLongPress,
currentState = rightActionText,
) )
} }
} }
@@ -0,0 +1,87 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledBottomSheet(
visible: Boolean,
onDismiss: () -> Unit,
backdrop: LayerBackdrop,
content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit
) {
if (!visible) return
val isDarkTheme = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
val isExpanded = sheetState.targetValue == SheetValue.Expanded
val progress by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
label = "sheetProgress"
)
val animatedCorner = lerp(48.dp, 42.dp, progress)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(animatedCorner),
scrimColor = Color.Transparent,
modifier = Modifier.padding(4.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(animatedCorner))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(animatedCorner) },
effects = {
vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
0xFFE0E0E0
).copy(alpha = 0.45f)
)
}
)
.padding(top = 24.dp)
.padding(horizontal = 16.dp)
) {
content(innerBackdrop, progress)
}
}
}
@@ -32,6 +32,11 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -61,6 +66,8 @@ import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2 import kotlin.math.atan2
@@ -68,6 +75,13 @@ import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.tanh import kotlin.math.tanh
enum class MaterialButtonStyle {
Tonal,
Normal,
Outlined,
Filled
}
@Composable @Composable
fun StyledButton( fun StyledButton(
onClick: () -> Unit, onClick: () -> Unit,
@@ -78,8 +92,46 @@ fun StyledButton(
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true, enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Tonal, // picking tonal because most usages assume a transparent/gray background, tonal will give a slightly less vibrant background
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
when (materialButtonStyle) {
MaterialButtonStyle.Filled -> {
Button(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
MaterialButtonStyle.Tonal -> {
FilledTonalButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
)
}
MaterialButtonStyle.Outlined -> {
OutlinedButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
MaterialButtonStyle.Normal -> {
TextButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content
)
}
}
}
DesignSystem.Apple -> {
val isInteractive = enabled && isInteractive val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
@@ -140,7 +192,7 @@ half4 main(float2 coord) {
} }
drawRect(color) drawRect(color)
} else { } else {
if (isPressed) { if (isPressed && enabled) {
drawRect(Color.Black.copy(alpha = 0.4f)) drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f)) drawRect(Color.White.copy(alpha = 0.2f))
} }
@@ -219,7 +271,8 @@ half4 main(float2 coord) {
blendMode = BlendMode.Plus blendMode = BlendMode.Plus
) )
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset =
pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) setFloatUniform("size", size.width, size.height)
setColorUniform( setColorUniform(
"color", "color",
@@ -262,11 +315,22 @@ half4 main(float2 coord) {
if (isInteractive) { if (isInteractive) {
Modifier.pointerInput(scope) { Modifier.pointerInput(scope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec =
spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
if (enabled) {
scope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } haptics.performHapticFeedback(
HapticFeedbackType.Reject
)
}
launch {
progressAnimation.animateTo(
0f,
progressAnimationSpec
)
}
launch { launch {
offsetAnimation.animateTo( offsetAnimation.animateTo(
Offset.Zero, Offset.Zero,
@@ -275,11 +339,17 @@ half4 main(float2 coord) {
} }
} }
} }
}
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
if (enabled) {
scope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
}
launch { launch {
progressAnimation.animateTo( progressAnimation.animateTo(
1f, 1f,
@@ -288,12 +358,14 @@ half4 main(float2 coord) {
} }
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}
}, },
onDragEnd = { onDragEnd = {
onDragStop() onDragStop()
}, },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
if (enabled) {
scope.launch { scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick HapticFeedbackType.SegmentFrequentTick
@@ -302,6 +374,7 @@ half4 main(float2 coord) {
} }
} }
} }
}
} else { } else {
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
detectTapGestures( detectTapGestures(
@@ -326,4 +399,6 @@ half4 main(float2 coord) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
content = content content = content
) )
}
}
} }
@@ -1,259 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import 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.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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.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 kotlinx.coroutines.launch
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
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
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
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
if (newHoveredIndex != hoveredIndex) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
}
hoveredIndex = newHoveredIndex
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) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd) }
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
) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
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)
)
}
}
}
}
}
}
}
}
@@ -18,16 +18,26 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -36,6 +46,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
@@ -44,6 +55,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.CompositingStrategy
@@ -52,11 +64,14 @@ import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastCoerceAtMost
@@ -67,9 +82,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.InnerShadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2 import kotlin.math.atan2
@@ -81,10 +98,79 @@ import kotlin.math.tanh
fun StyledIconButton( fun StyledIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: String, icon: String,
tint: Color = Color.Unspecified, iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(), backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit onClick: () -> Unit,
enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
) { ) {
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
when (materialButtonStyle) {
MaterialButtonStyle.Tonal -> {
FilledTonalIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Filled -> {
FilledIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Outlined -> {
OutlinedIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
MaterialButtonStyle.Normal -> {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(52.dp)
) {
Text(
text = icon,
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
DesignSystem.Apple -> {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -96,6 +182,7 @@ fun StyledIconButton(
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val density = LocalDensity.current
val interactiveHighlightShader = remember { val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -120,8 +207,10 @@ half4 main(float2 coord) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
TextButton( TextButton(
onClick = { onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick() onClick()
}
}, },
shape = RoundedCornerShape(56.dp), shape = RoundedCornerShape(56.dp),
modifier = modifier modifier = modifier
@@ -130,13 +219,17 @@ half4 main(float2 coord) {
backdrop = backdrop, backdrop = backdrop,
shape = { RoundedCornerShape(56.dp) }, shape = { RoundedCornerShape(56.dp) },
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
shadow = { innerShadow = {
Shadow( if (isDarkTheme) {
radius = 12f.dp, InnerShadow(
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f) radius = 0.5.dp,
offset = DpOffset(1.dp, 1.dp),
color = Color.White.copy(0.6f),
) )
} else InnerShadow()
}, },
layerBlock = { layerBlock = {
if (!enabled) return@drawBackdrop
val width = size.width val width = size.width
val height = size.height val height = size.height
@@ -146,8 +239,10 @@ half4 main(float2 coord) {
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
val offset = offsetAnimation.value val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) translationX =
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
@@ -161,6 +256,12 @@ half4 main(float2 coord) {
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
if (!enabled) {
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
)
return@drawBackdrop
}
val progress = progressAnimation.value.coerceIn(0f, 1f) val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp) val shape = RoundedCornerShape(56.dp)
@@ -187,6 +288,18 @@ half4 main(float2 coord) {
} }
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
if (surfaceColor.isSpecified) {
drawRect(surfaceColor)
}
if (!isDarkTheme) {
drawOutline(
outline = outline,
color = Color.Black.copy(0.4f),
style = Stroke(1f),
)
}
drawRect( drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy( (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn( progress.coerceIn(
@@ -197,6 +310,7 @@ half4 main(float2 coord) {
) )
}, },
onDrawFront = { onDrawFront = {
if (!enabled) return@drawBackdrop
val progress = progressAnimation.value.fastCoerceIn(0f, 1f) val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) { if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
@@ -241,25 +355,45 @@ half4 main(float2 coord) {
) )
.pointerInput(scope) { .pointerInput(scope) {
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
if (enabled) {
scope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch {
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } progressAnimation.animateTo(
0f,
progressAnimationSpec
)
}
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
}
} }
} }
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
if (enabled) {
pressStartPosition = down.position pressStartPosition = down.position
scope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}
}, },
onDragEnd = { onDragStop() }, onDragEnd = { onDragStop() },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
scope.launch { scope.launch {
if (enabled) {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick HapticFeedbackType.SegmentFrequentTick
) )
@@ -267,16 +401,37 @@ half4 main(float2 coord) {
} }
} }
} }
.size(48.dp), }
.size(with(density) { 48.sp.toDp() }),
) { ) {
Text( Text(
text = icon, text = icon,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 20.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black, color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
}
}
}
@Preview(uiMode = UI_MODE_NIGHT_NO, name = "Light")
@Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark")
@Composable
fun StyledIconButtonPreview() {
Box(modifier = Modifier
.height(120.dp)
.width(200.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
RoundedCornerShape(28.dp)
), contentAlignment = Alignment.Center) {
StyledIconButton(
icon = "􀍟",
onClick = { }
)
}
} }
@@ -0,0 +1,178 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@Composable
fun StyledInputField(
inputState: TextFieldState,
focusRequester: FocusRequester,
placeholder: String = "",
singleLine: Boolean = true,
forceApple: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
if(m3eEnabled) {
TextField(
state = inputState,
placeholder = {
Text(
text = placeholder,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
},
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
}
else {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val minHeight = if (singleLine) 58.dp else 120.dp
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
val hasText = inputState.text.isNotEmpty()
val density = LocalDensity.current
val spacerHeight by animateDpAsState(
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
label = "labelSpacer"
)
val transition = updateTransition(hasText, label = "floating")
val yOffset by transition.animateDp(label = "y") {
if (it) with(density) { (-48).sp.toDp() } else 0.dp
}
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = verticalAlignment,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures {
focusRequester.requestFocus()
}
}
) {
BasicTextField(
state = inputState,
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
verticalAlignment = verticalAlignment,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box(
modifier = Modifier
.weight(1f),
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
) {
Text(
text = placeholder,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.8f)
),
modifier = Modifier
.offset(y = yOffset)
)
innerTextField()
}
}
if (singleLine && !inputState.text.isEmpty()) {
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.6f
)
),
)
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
}
}
}
@@ -0,0 +1,132 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledList(
modifier: Modifier = Modifier,
title: String? = null,
description: String? = null,
content: @Composable StyledListScope.() -> Unit
) {
val scope = StyledListScope()
scope.content()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column (modifier = modifier) {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
.clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
) {
if (m3eEnabled && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(0.8f),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
}
scope.items.forEachIndexed { index, item ->
item(index, scope.items.size)
}
Spacer(modifier = Modifier.height(if(m3eEnabled) 4.dp else 0.dp))
}
}
if (!m3eEnabled && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodySmallEmphasized,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
}
}
class StyledListScope {
internal val items =
mutableListOf<@Composable (Int, Int) -> Unit>()
fun item(
content: @Composable (index: Int, count: Int) -> Unit
) {
items += content
}
}
@Preview(showBackground = true, wallpaper = GREEN_DOMINATED_EXAMPLE, uiMode = UI_MODE_NIGHT_YES)
@Composable
fun StyledListDemo() {
LibrePodsTheme(
m3eEnabled = true
) {
val backgroundC = MaterialTheme.colorScheme.background
StyledScaffold(
title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}"
) {
Column (
modifier = Modifier.padding(horizontal = 12.dp)
) {
Spacer(modifier = Modifier.height(56.dp))
StyledList(
title = "hello"
) {
for (i in 0..2) {
StyledListItem(
name = i.toString(),
onClick = {}
)
}
val checked = remember { mutableStateOf(false) }
StyledToggle(
label = "Test",
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit mollit anim id est laborum.",
checked = checked.value,
onCheckedChange = { checked.value = it },
)
}
}
}
}
}
@@ -0,0 +1,409 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
@Composable
fun StyledListItem(
modifier: Modifier = Modifier,
title: String? = null,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(
if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp))
) {
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
height = height,
enabled = enabled,
index = 0,
count = 1,
orientation = orientation,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
}
@Composable
fun StyledListScope.StyledListItem(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)? = null,
description: String? = null,
enabled: Boolean = onClick != null,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
item { index, count ->
StyledListItemContent(
name = name,
onClick = onClick,
description = description,
enabled = enabled,
index = index,
count = count,
orientation = orientation,
modifier = modifier,
selected = selected,
leadingContent = leadingContent,
trailingContent = trailingContent
)
}
}
enum class ListItemOrientation{
Horizontal,
Vertical
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledListItemContent(
modifier: Modifier = Modifier,
name: String,
onClick: (() -> Unit)?,
description: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true,
index: Int,
count: Int,
orientation: ListItemOrientation = ListItemOrientation.Horizontal,
selected: Boolean? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null
) {
val isDarkTheme = isSystemInDarkTheme()
val surfaceColor = MaterialTheme.colorScheme.surface
val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim
var backgroundColor by remember { mutableStateOf(surfaceColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
when (LocalDesignSystem.current) {
DesignSystem.Apple -> {
val trailingContentDefault: @Composable () -> Unit = {
if (trailingContent == null) {
if (onClick != null) {
if (selected != null) {
val floatAnimateState by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
} else {
Text(
text = "􀯻",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
modifier = Modifier
.padding(start = if (description != null) 6.dp else 0.dp)
)
}
}
} else {
trailingContent()
}
}
Column (
modifier = Modifier
.background(
animatedBackgroundColor,
when {
(index == 0 && count == 1) -> {
RoundedCornerShape(28.dp)
}
(index == 0) -> {
RoundedCornerShape(
topStart = 28.dp,
topEnd = 28.dp,
bottomStart = 0.dp,
bottomEnd = 0.dp
)
}
(index + 1 == count) -> {
RoundedCornerShape(
topStart = 0.dp,
topEnd = 0.dp,
bottomStart = 28.dp,
bottomEnd = 28.dp
)
}
else -> {
RectangleShape
}
}
)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor = surfaceDimColor
tryAwaitRelease()
backgroundColor = surfaceColor
}
},
onTap = {
if (enabled) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.ContextClick
)
}
onClick?.invoke()
}
}
)
}
.heightIn(min = height)
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier
.heightIn(min = height)
.padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (leadingContent != null) {
leadingContent()
Spacer(modifier = Modifier.width(12.dp))
}
Column (verticalArrangement = Arrangement.Center) {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
if (description != null && orientation == ListItemOrientation.Vertical) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme
)
}
}
Spacer(modifier = Modifier.weight(1f))
if (orientation == ListItemOrientation.Horizontal && description != null) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme
)
}
trailingContentDefault()
}
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = if (leadingContent != null) 12.dp else 0.dp)
)
}
}
}
DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
SegmentedListItem(
modifier = modifier.heightIn(min = 64.dp),
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = onClick ?: {},
leadingContent = leadingContent,
trailingContent = {
if (trailingContent == null) {
if (onClick != null) {
if (selected == true) {
Icon(Icons.Default.Check, contentDescription = null)
} else if (selected == null) {
Icon(
Icons.AutoMirrored.Default.KeyboardArrowRight,
contentDescription = null
)
}
}
} else {
trailingContent()
}
},
supportingContent = {
if (description != null) Text(
description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 4.dp)
)
},
content = {
Text(
text = name,
style = MaterialTheme.typography.labelMediumEmphasized,
modifier = Modifier.padding(
top = 4.dp,
bottom = if (description != null) 0.dp else 4.dp
)
)
},
verticalAlignment = Alignment.CenterVertically,
colors = if (onClick == null) {
ListItemDefaults.segmentedColors().run {
copy(
disabledContentColor = contentColor,
disabledSupportingContentColor = supportingContentColor,
disabledTrailingContentColor = trailingContentColor
)
}
} else ListItemDefaults.segmentedColors(),
enabled = onClick != null && enabled,
selected = selected ?: false
)
if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
}
}
}
}
}
@@ -18,6 +18,15 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -27,11 +36,23 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -45,39 +66,116 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
modifier: Modifier = Modifier,
visible: Boolean = true,
title: String, title: String,
showBackButton: Boolean = false,
onNavigateBack: () -> Unit = {},
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit content: @Composable () -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
when (LocalDesignSystem.current) {
DesignSystem.Material -> {
Scaffold( Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
) {
TopAppBar(
navigationIcon = {
if (showBackButton) {
Row {
Spacer(modifier = Modifier.width(12.dp))
FilledTonalIconButton(
onClick = onNavigateBack,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
}
}
},
title = {
Crossfade(targetState = title) {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
style = MaterialTheme.typography.titleSmall
)
}
},
actions = {
actionButtons.forEach { actionButton ->
actionButton(rememberLayerBackdrop())
}
Spacer(modifier = Modifier.width(12.dp))
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
)
}
},
) { paddingValues ->
Box(
modifier = modifier
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
.fillMaxSize()
.hazeSource(hazeState)
) {
content()
}
}
}
DesignSystem.Apple -> {
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier modifier = Modifier
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else 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)) .clip(RoundedCornerShape(52.dp))
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
@@ -87,24 +185,69 @@ fun StyledScaffold(
.padding(start = startPadding, end = endPadding) .padding(start = startPadding, end = endPadding)
) { ) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
Box( val bgColor = MaterialTheme.colorScheme.surfaceContainer
AnimatedVisibility(
visible = showBackButton,
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier
.zIndex(3f)
.padding(top = topPadding, start = 8.dp)
.align(Alignment.TopStart)
) {
StyledIconButton(
onClick = onNavigateBack,
icon = "􀯶",
backdrop = backdrop
)
}
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.height(64.dp + topPadding) .height(64.dp + topPadding)
.fillMaxWidth() .fillMaxWidth()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeEffect(state = hazeState) { ){
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White)) Box(
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) modifier = Modifier.hazeEffect(
state = hazeState,
) {
backgroundColor = bgColor
tints = listOf(
HazeTint(
if (isDarkTheme) Color.Black.copy(0.55f) else Color(
0xFFF2F2F7
).copy(alpha = 0.85f)
)
)
blurRadius = 6.dp
} }
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding + 12.dp)) Spacer(modifier = Modifier.height(topPadding + 12.dp))
Crossfade(targetState = title) {
Text( Text(
text = title, text = it,
style = TextStyle( style = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
color = if (isDarkTheme) Color.White else Color.Black, color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
@@ -113,51 +256,40 @@ fun StyledScaffold(
) )
} }
} }
Row( }
}
AnimatedVisibility(
visible = visible && actionButtons.isNotEmpty(),
enter = fadeIn() + scaleIn(
initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut() + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier modifier = Modifier
.zIndex(3f) .zIndex(3f)
.padding(top = topPadding, end = 8.dp) .padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
) { ) {
Row{
actionButtons.forEach { actionButton -> actionButtons.forEach { actionButton ->
actionButton(backdrop) actionButton(backdrop)
} }
} }
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
} }
}
}
Box(
@Composable modifier = modifier
fun StyledScaffold( .hazeSource(hazeState)
title: String, .fillMaxSize()
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), ) {
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit
) {
StyledScaffold(
title = title,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
) { _, _, _->
content() content()
} }
} }
}
@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,187 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
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.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 visible: Boolean = true,
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
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val visibleItems = items.filter { it.visible }
visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0
val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null
val shape = when {
isFirst && isLast -> RoundedCornerShape(28.dp)
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(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
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 = {
if (item.enabled) {
itemBackgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
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)
)
}
}
}
}
}
@@ -34,14 +34,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Slider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -71,6 +75,7 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -90,6 +95,9 @@ import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.utils.inspectDragGestures import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -221,21 +229,152 @@ fun StyledSlider(
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false,
description: String? = null, description: String? = null,
enabled: Boolean = true enabled: Boolean = true,
index: Int = 0,
count: Int = 1
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) when (LocalDesignSystem.current) {
val isLightTheme = !isSystemInDarkTheme() DesignSystem.Material -> {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
)
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
}
Column {
label?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelSmallEmphasized,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 12.dp)
)
}
SegmentedListItem(
shapes = ListItemDefaults.shapes().copy(
shape = defaultShape,
pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
hoveredShape = RoundedCornerShape(24.dp),
),
onClick = {},
enabled = enabled,
modifier = Modifier.heightIn(min = 58.dp),
content = {
Column(
modifier = Modifier.fillMaxWidth()
) {
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium
)
}
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier.fillMaxWidth()
) {
startLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall
)
}
Spacer(Modifier.weight(1f))
endLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall
)
}
}
}
}
},
supportingContent = {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
startIcon?.let {
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
Spacer(Modifier.width(12.dp))
}
Slider(
modifier = Modifier.weight(1f),
value = value,
onValueChange = { newValue ->
val snapped =
if (snapPoints.isNotEmpty()) {
snapIfClose(
newValue,
snapPoints,
snapThreshold
)
} else {
newValue
}
onValueChange(snapped)
},
valueRange = valueRange,
enabled = enabled
)
endIcon?.let {
Spacer(Modifier.width(12.dp))
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
}
}
}
}
)
if (index + 1 != count) {
Spacer(
modifier = Modifier.height(2.dp)
)
}
}
}
DesignSystem.Apple -> {
val backgroundColor =
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isDarkTheme = isSystemInDarkTheme()
val trackColor = val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f) if (isDarkTheme) Color(0xFF787880).copy(0.36f)
else Color(0xFF787880).copy(0.36f) else Color(0xFF787878).copy(0.2f)
val accentColor = val accentColor =
if (enabled) { if (enabled) {
if (isLightTheme) Color(0xFF0088FF) if (isDarkTheme) Color(0xFF0091FF)
else Color(0xFF0091FF) else Color(0xFF0088FF)
} else { } else {
trackColor trackColor
} }
val labelTextColor = if (isLightTheme) Color.Black else Color.White val labelTextColor = if (isDarkTheme) Color.White else Color.Black
val fraction by derivedStateOf { val fraction by derivedStateOf {
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
@@ -302,7 +441,11 @@ fun StyledSlider(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier), .then(
if (startIcon == null && endIcon == null) Modifier.padding(
horizontal = 8.dp
) else Modifier
),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -320,14 +463,17 @@ fun StyledSlider(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.onGloballyPositioned { .onGloballyPositioned {
startIconWidthState.floatValue = it.size.width.toFloat() startIconWidthState.floatValue =
it.size.width.toFloat()
} }
) )
} }
Box( Box(
Modifier Modifier
.weight(1f) .weight(1f)
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() } .onSizeChanged {
trackWidthState.floatValue = it.width.toFloat()
}
.onGloballyPositioned { .onGloballyPositioned {
trackPositionState.floatValue = trackPositionState.floatValue =
it.positionInParent().y + it.size.height / 2f it.positionInParent().y + it.size.height / 2f
@@ -369,19 +515,27 @@ fun StyledSlider(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
.onGloballyPositioned { .onGloballyPositioned {
endIconWidthState.floatValue = it.size.width.toFloat() endIconWidthState.floatValue =
it.size.width.toFloat()
} }
) )
} }
} }
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp)) if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(
modifier = Modifier.height(4.dp)
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
if (snapPoints.isNotEmpty()) { 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 trackWidth =
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
density
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
density
) { 22.dp.toPx() }
val startOffset = val startOffset =
if (startIcon != null) startIconWidthState.floatValue + with( if (startIcon != null) startIconWidthState.floatValue + with(
density density
@@ -417,10 +571,6 @@ fun StyledSlider(
Box( Box(
Modifier Modifier
.graphicsLayer { .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 = val startOffset =
if (startIcon != null) if (startIcon != null)
startIconWidthState.floatValue + with(density) { 24.dp.toPx() } startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
@@ -433,7 +583,12 @@ fun StyledSlider(
startOffset - size.width / 4f, startOffset - size.width / 4f,
startOffset + trackWidthState.floatValue - size.width * 3f / 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() } translationY =
if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(
density
) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(
density
) { 8.dp.toPx() }
} }
.then( .then(
if (enabled) { if (enabled) {
@@ -442,7 +597,8 @@ fun StyledSlider(
rememberDraggableState { delta -> rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) { if (trackWidth > 0f) {
val targetFraction = fraction + delta / trackWidth val targetFraction =
fraction + delta / trackWidth
val targetValue = val targetValue =
lerp( lerp(
valueRange.start, valueRange.start,
@@ -455,12 +611,16 @@ fun StyledSlider(
) )
snapPoints.forEach { snap -> snapPoints.forEach { snap ->
if ((lastDragValue < snap && targetValue >= snap) || if ((lastDragValue < snap && targetValue >= snap) ||
(snap in targetValue..<lastDragValue)) { (snap in targetValue..<lastDragValue)
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) ) {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
} }
} }
lastDragValue = targetValue lastDragValue = targetValue
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( val snappedValue =
if (snapPoints.isNotEmpty()) snapIfClose(
targetValue, targetValue,
snapPoints, snapPoints,
snapThreshold, snapThreshold,
@@ -502,8 +662,14 @@ fun StyledSlider(
scaleX = momentumAnimation.scaleX scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f) scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f) -0.15f,
0.15f
)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(
-0.15f,
0.15f
)
}, },
onDrawSurface = { onDrawSurface = {
val progress = momentumAnimation.progress val progress = momentumAnimation.progress
@@ -530,8 +696,7 @@ fun StyledSlider(
} }
if (independent) { if (independent) {
Column(
Column (
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
@@ -566,7 +731,9 @@ fun StyledSlider(
style = TextStyle( style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier modifier = Modifier
@@ -575,10 +742,18 @@ fun StyledSlider(
} }
} }
} else { } else {
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false") if (label != null) Log.w(
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false") "StyledSlider",
"Label is ignored when independent is false"
)
if (description != null) Log.w(
"StyledSlider",
"Description is ignored when independent is false"
)
content() content()
} }
}
}
} }
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float { private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
@@ -586,21 +761,21 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
return if (abs(nearest - value) <= threshold) nearest else value return if (abs(nearest - value) <= threshold) nearest else value
} }
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
@Composable @Composable
fun StyledSliderPreview() { fun StyledSliderPreview() {
val a = remember { mutableFloatStateOf(0.5f) } val a = remember { mutableFloatStateOf(0.5f) }
Box( LibrePodsTheme(
Modifier m3eEnabled = true
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
.padding(16.dp)
.fillMaxSize()
) { ) {
Column ( StyledScaffold(
Modifier.align(Alignment.Center), title = "test",
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) ) {
{ Spacer(modifier = Modifier.height(72.dp))
StyledSlider( StyledSlider(
value = a.floatValue, value = a.floatValue,
onValueChange = { onValueChange = {
@@ -614,6 +789,8 @@ fun StyledSliderPreview() {
endIcon = "B", endIcon = "B",
) )
StyledSlider( StyledSlider(
label = "Small label",
description = "This is a somewhat long descriptionRes",
value = a.floatValue, value = a.floatValue,
onValueChange = { onValueChange = {
a.floatValue = it a.floatValue = it
@@ -624,8 +801,8 @@ fun StyledSliderPreview() {
independent = true, independent = true,
startIcon = "A", startIcon = "A",
endIcon = "B", endIcon = "B",
enabled = false
) )
} }
} }
}
} }
@@ -20,8 +20,6 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -33,8 +31,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,9 +47,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -52,12 +57,15 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
@@ -66,9 +74,102 @@ fun StyledToggle(
label: String, label: String,
description: String? = null, description: String? = null,
checked: Boolean = false, checked: Boolean = false,
independent: Boolean = true,
enabled: Boolean = true, enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit,
header: Boolean = false
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column(modifier = Modifier.padding(vertical = 12.dp)) {
title?.let {
Box(
modifier = Modifier
.background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp)
) {
Text(
text = it,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(
if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface,
RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)
)
.clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp))
) {
if (m3eEnabled) {
StyledToggleContent(
label = label,
description = description,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = 0,
count = 1,
header = header
)
} else {
StyledToggleContent(
label = label,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = 0,
count = 1
)
}
}
if (description != null && !m3eEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description, style = TextStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
), modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
}
@Composable
fun StyledListScope.StyledToggle(
label: String,
description: String? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
item { index, count ->
StyledToggleContent(
label = label,
description = description,
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
index = index,
count = count
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun StyledToggleContent(
label: String,
description: String? = null,
checked: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
index: Int,
count: Int,
header: Boolean = false
) { ) {
val currentChecked by rememberUpdatedState(checked) val currentChecked by rememberUpdatedState(checked)
@@ -78,118 +179,82 @@ fun StyledToggle(
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var backgroundColor by remember { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) if (m3eEnabled) {
val defaultShape = when {
count == 1 -> RoundedCornerShape(24.dp)
index == 0 -> RoundedCornerShape(
topStart = 24.dp,
topEnd = 24.dp,
bottomStart = 8.dp,
bottomEnd = 8.dp
) )
index == count - 1 -> RoundedCornerShape(
topStart = 8.dp,
topEnd = 8.dp,
bottomStart = 24.dp,
bottomEnd = 24.dp
)
else -> RoundedCornerShape(8.dp)
} }
Column {
val animatedBackgroundColor by animateColorAsState( SegmentedListItem(
targetValue = backgroundColor, shapes = ListItemDefaults.shapes().copy(
animationSpec = tween(durationMillis = 500) shape = defaultShape,
) pressedShape = RoundedCornerShape(24.dp),
selectedShape = RoundedCornerShape(24.dp),
if (independent) { hoveredShape = RoundedCornerShape(24.dp),
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding( onClick = { onCheckedChange(!currentChecked) },
start = 16.dp, trailingContent = {
end = 16.dp, Switch(
top = 8.dp, checked = currentChecked,
bottom = 4.dp onCheckedChange = onCheckedChange,
modifier = Modifier.padding(end = if (header) 8.dp else 0.dp),
enabled = enabled
) )
) },
} supportingContent = description?.let {
{
Box( Text(
text = it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp)) .padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp)
.padding(4.dp) .padding(horizontal = if (header) 8.dp else 0.dp),
.pointerInput(Unit) { color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
detectTapGestures( )
onPress = {
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
} }
}, },
onTap = { content = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = label, text = label,
modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelMediumEmphasized,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(it)
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(
.background( top = if (header) 8.dp else 4.dp,
if (isDarkTheme) Color(0xFF000000) bottom = if (header) 2.dp else 4.dp
else Color(0xFFF2F2F7)
) )
) { .padding(horizontal = if (header) 8.dp else 0.dp),
Text( color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
},
enabled = enabled,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = 64.dp),
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
) )
} if (index+1 != count) {
Spacer(modifier = Modifier.height(2.dp))
} }
} }
} else { } else {
val isPressed = remember { mutableStateOf(false) } val isPressed = remember { mutableStateOf(false) }
Column {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -225,24 +290,16 @@ fun StyledToggle(
) { ) {
Text( Text(
text = label, text = label,
style = TextStyle( style = MaterialTheme.typography.labelMedium,
fontSize = 16.sp, color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
) )
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) { if (description != null) {
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = description, text = description,
style = TextStyle( style = MaterialTheme.typography.bodySmall,
fontSize = 12.sp, color = textColor.copy(0.8f)
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
) )
} }
} }
@@ -257,17 +314,74 @@ fun StyledToggle(
} }
) )
} }
if (index+1 != count) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp,end = 12.dp)
)
}
}
} }
} }
@Preview @Preview(name = "List", group = "Apple")
@Composable @Composable
fun StyledTogglePreview() { fun StyledToggleAppleListPreview() {
val checked = remember { mutableStateOf(false) } val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = false) {
StyledList {
StyledToggle( StyledToggle(
label = "Example Toggle", label = "Apple Styled List",
description = "This is an example description for the styled toggle.", description = "This is an example description for the styled toggle.",
checked = checked.value, checked = checked.value,
onCheckedChange = { checked.value = !checked.value } onCheckedChange = { checked.value = !checked.value }
) )
}
}
}
@Preview(name = "Normal", group = "Apple")
@Composable
fun StyledToggleApplePreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = false) {
StyledToggle(
label = "Apple",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
@Preview(name = "List", group = "Apple")
@Composable
fun StyledToggleM3EListPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = true) {
StyledList {
StyledToggle(
label = "Apple Styled List",
// description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
}
}
@Preview(name = "Normal", group = "Material")
@Composable
fun StyledToggleM3EPreview() {
val checked = remember { mutableStateOf(false) }
LibrePodsTheme(m3eEnabled = true) {
StyledToggle(
label = "Material",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}
} }
@@ -0,0 +1,324 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CallControlScreen
import me.kavishdevar.librepods.presentation.screens.EqualizerRoute
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LoadingScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestRoute
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AppNavGraph(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
backStack: SnapshotStateList<Screen>,
airPodsViewModel: AirPodsViewModel,
) {
val navigate: (Screen) -> Unit = { screen ->
backStack.add(screen)
}
fun navigateToPurchase() {
navigate(Screen.Purchase)
}
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
SharedTransitionLayout {
NavDisplay(
sharedTransitionScope = this,
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
},
entryProvider = { screen ->
when (screen) {
Screen.Onboarding ->
NavEntry(screen) {
OnboardingScreen {
onboardingComplete()
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings)
backStack.remove(screen)
}
}
Screen.AirPodsSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AirPodsSettingsRoute(
viewModel = airPodsViewModel,
navigateToRename = { navigate(Screen.Rename) },
navigateToHearingProtection = { navigate(Screen.HearingProtection) },
navigateToHearingAid = { navigate(Screen.HearingAid) },
navigateToLeftLongPress = {
navigate(
Screen.LongPress("Left")
)
},
navigateToRightLongPress = {
navigate(
Screen.LongPress("Right")
)
},
navigateToPurchase = { navigate(Screen.Purchase) },
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) },
navigateToEqualizer = { navigate(Screen.Equalizer) },
navigateToHeadTracking = { navigate(Screen.HeadTracking) },
navigateToAccessibility = { navigate(Screen.Accessibility) },
navigateToVersion = { navigate(Screen.VersionInfo) },
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToCallControlScreen = { navigate(Screen.CallControl(it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) },
)
}
Screen.Rename ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
RenameScreen(airPodsViewModel)
}
Screen.AppSettings ->
NavEntry(screen) {
val vm: AppSettingsViewModel = viewModel()
AppSettingsScreen(
viewModel = vm,
navigateToPurchase = ::navigateToPurchase,
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) }
)
}
Screen.Troubleshooting ->
NavEntry(screen) {
TroubleshootingScreen()
}
Screen.HeadTracking ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase)
}
Screen.Accessibility ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AccessibilitySettingsScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) }
)
}
Screen.TransparencyCustomization ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
TransparencySettingsScreen(airPodsViewModel)
}
Screen.HearingAid ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidScreen(
viewModel = airPodsViewModel,
onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) },
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) },
)
}
Screen.HearingAidAdjustments ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingAidAdjustmentsScreen(airPodsViewModel)
}
Screen.AdaptiveStrength ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase)
}
// Screen.CameraControl ->
// NavEntry(screen) {
// CameraControlScreen(airPodsViewModel)
// }
Screen.OpenSourceLicenses ->
NavEntry(screen) {
OpenSourceLicensesScreen()
}
Screen.UpdateHearingTest ->
NavEntry(screen) {
UpdateHearingTestRoute(airPodsViewModel)
}
Screen.VersionInfo ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
VersionScreen(airPodsViewModel)
}
Screen.HearingProtection ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
HearingProtectionScreen(
viewModel = airPodsViewModel,
navigateToPurchase = ::navigateToPurchase
)
}
Screen.Purchase ->
NavEntry(screen) {
val vm: PurchaseViewModel = viewModel()
PurchaseScreen(vm, backStack)
}
Screen.Equalizer ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
EqualizerRoute(airPodsViewModel)
}
is Screen.LongPress ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
LongPress(
viewModel = airPodsViewModel,
name = screen.bud,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
CallControlScreen(
viewModel = airPodsViewModel,
action = screen.action,
onCallControlValueChanged = { flipped ->
airPodsViewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
0x00,
0x03
)
)
}
)
}
is Screen.MicrophoneSettings ->
NavEntry(screen) {
if (!airPodsViewModel.isReady) LoadingScreen()
MicrophoneSettingsRoute(viewModel = airPodsViewModel)
}
is Screen.ReleaseNotes ->
NavEntry(screen) {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.AirPodsSettings)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
}
},
transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 }
},
popTransitionSpec = {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
},
predictivePopTransitionSpec = { swipeEdge ->
if (m3eEnabled) {
val enterOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ -it / 6 }
} else {
{ it / 6 }
}
val exitOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ it / 8 }
} else {
{ -it / 8 }
}
fadeIn(
animationSpec = tween(250)
) +
slideInHorizontally(
initialOffsetX = enterOffset,
animationSpec = tween(250)
) togetherWith
fadeOut(
targetAlpha = 0.75f,
animationSpec = tween(250)
) +
scaleOut(
targetScale = 0.85f,
animationSpec = tween(250)
) +
slideOutHorizontally(
targetOffsetX = exitOffset,
animationSpec = tween(250)
)
} else {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
}
},
)
}
}
@@ -0,0 +1,158 @@
package me.kavishdevar.librepods.presentation.navigation
import android.util.Log
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.kyant.backdrop.backdrops.LayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun NavigationRoot(
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
showOnboarding: Boolean = false,
onboardingComplete: () -> Unit = {},
airPodsViewModel: AirPodsViewModel
) {
val backStack = remember {
mutableStateListOf(
when {
showOnboarding -> Screen.Onboarding
showReleaseNotes -> Screen.ReleaseNotes
else -> Screen.AirPodsSettings
}
)
}
val currentScreen = backStack.last()
val state by airPodsViewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val title = when (currentScreen) {
Screen.Onboarding -> ""
Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name)
Screen.Accessibility -> stringResource(R.string.accessibility)
Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
Screen.AppSettings -> stringResource(R.string.settings)
// Screen.CameraControl -> stringResource(R.string.camera_control)
Screen.Equalizer -> stringResource(R.string.equalizer)
Screen.HeadTracking -> stringResource(R.string.head_tracking)
Screen.HearingAid -> stringResource(R.string.hearing_aid)
Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
Screen.HearingProtection -> stringResource(R.string.hearing_protection)
is Screen.LongPress -> currentScreen.bud
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
Screen.Rename -> stringResource(R.string.name)
Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
Screen.VersionInfo -> stringResource(R.string.version)
is Screen.CallControl -> currentScreen.action
Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
Screen.ReleaseNotes -> ""
}
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
val actionButtons = when (currentScreen) {
Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconButton(
onClick = { backStack.add(Screen.AppSettings) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = { backStack.add(Screen.AppSettings) },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}
}
)
Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconToggleButton(
checked = state.headTrackingActive,
onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize)
)
}
} else {
StyledIconButton(
onClick = {
if (!state.headTrackingActive) {
airPodsViewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
airPodsViewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (state.headTrackingActive) "􀊅" else "􀊃",
backdrop = scaffoldBackdrop
)
}
}
)
else -> listOf()
}
StyledScaffold(
visible = currentScreen.showTopBar,
title = title,
showBackButton = backStack.size > 1,
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
actionButtons = actionButtons
) {
AppNavGraph(
showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown,
showOnboarding = showOnboarding,
onboardingComplete = onboardingComplete,
backStack = backStack,
airPodsViewModel = airPodsViewModel,
)
}
}
@@ -0,0 +1,84 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen: NavKey {
val showTopBar: Boolean
get() = true
@Serializable
data object Onboarding: Screen {
override val showTopBar: Boolean = false
}
@Serializable
data object AirPodsSettings: Screen
@Serializable
data object Rename: Screen
@Serializable
data object AppSettings: Screen
@Serializable
data object Troubleshooting: Screen
@Serializable
data object HeadTracking: Screen
@Serializable
data object Accessibility: Screen
@Serializable
data object TransparencyCustomization: Screen
@Serializable
data object HearingAid: Screen
@Serializable
data object HearingAidAdjustments: Screen
@Serializable
data object AdaptiveStrength: Screen
// @Serializable
// data object CameraControl: Screen
@Serializable
data object OpenSourceLicenses: Screen
@Serializable
data object UpdateHearingTest: Screen
@Serializable
data object VersionInfo: Screen
@Serializable
data object HearingProtection: Screen
@Serializable
data object Purchase: Screen
@Serializable
data object Equalizer: Screen
@Serializable
data class LongPress(
val bud: String
): Screen
@Serializable
data class CallControl(
val action: String
): Screen
@Serializable
data object MicrophoneSettings: Screen
@Serializable
data object ReleaseNotes: Screen {
override val showTopBar: Boolean = false
}
}
@@ -33,6 +33,7 @@ import android.content.IntentFilter
import android.content.res.Resources import android.content.res.Resources
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -374,6 +375,7 @@ class IslandWindow(private val context: Context) {
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view) val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
videoView.setVideoURI(videoUri) videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer -> videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true mediaPlayer.isLooping = true
@@ -712,8 +714,16 @@ class IslandWindow(private val context: Context) {
} }
isClosing = false isClosing = false
// Make sure all animations are canceled // Make sure all animations are canceled
try {
springAnimation.cancel() springAnimation.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling spring animation $e")
}
try {
flingAnimator.cancel() flingAnimator.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling fling animation $e")
}
} }
fun forceClose() { fun forceClose() {
@@ -29,6 +29,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -137,6 +138,7 @@ class PopupWindow(
updateBatteryStatus(batteryNotification) updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video) val vid = mView.findViewById<VideoView>(R.id.video)
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height) vid.resolveAdjustedSize(vid.width, vid.height)
vid.start() vid.start()
@@ -22,89 +22,67 @@ package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
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.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.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 com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledDropdown import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
@Composable @Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) { fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled = val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1 1
@@ -113,42 +91,36 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
0 0
)?.toInt() == 1 )?.toInt() == 1
val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.accessibility) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
Spacer(modifier = Modifier.height(16.dp))
} }
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
@@ -211,7 +183,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel() phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150) delay(150.milliseconds)
try { try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
@@ -228,73 +200,51 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
} }
Box(
modifier = Modifier.then( StyledList(
if (!state.isPremium) { title = stringResource(R.string.press_speed),
Modifier.pointerInput(Unit) { description = stringResource(R.string.press_speed_description)
awaitPointerEventScope { ) {
while (true) { pressSpeedOptions.forEach { (value, label) ->
val event = awaitPointerEvent(PointerEventPass.Initial) StyledListItem(
event.changes.forEach { it.consume() } name = label,
} selected = selectedPressSpeed == label,
} onClick = {
} selectedPressSpeed = label
} else Modifier)) {
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
viewModel.setControlCommandByte( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 0.toByte()
) )
}, }
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
}
StyledList(
title = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description)
) {
pressAndHoldDurationOptions.forEach { (value, label) ->
StyledListItem(
name = label,
selected = selectedPressAndHoldDuration == label,
onClick = {
selectedPressAndHoldDuration = label
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent(
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
viewModel.setControlCommandByte( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
)
}
}
StyledToggle( StyledToggle(
title = stringResource(R.string.noise_control), title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull( checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0 0
) == 0x01.toByte(), ) == 0x01.toByte(),
@@ -322,27 +272,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
if (!hearingAidEnabled && state.vendorIdHook) { if (!hearingAidEnabled && state.vendorIdHook) {
NavigationButton( StyledListItem(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), name = stringResource(R.string.customize_transparency_mode),
navController = navController, onClick = navigateToTransparencyCustomization,
enabled = state.isPremium enabled = state.isPremium
) )
} }
val toneVolumeValue = val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0 LaunchedEffect(toneVolumeValue) {
)?.toFloat() ?: 75f snapshotFlow {
StyledSlider( toneVolumeValue.floatValue
label = stringResource(R.string.tone_volume), }
description = stringResource(R.string.tone_volume_description), .debounce(100.milliseconds)
value = toneVolumeValue, .collect {
onValueChange = {
viewModel.setControlCommandValue( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50) byteArrayOf(it.toInt().toByte(), 0x50)
) )
}
}
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue.floatValue,
onValueChange = {
toneVolumeValue.floatValue = it
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
@@ -369,40 +326,28 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
enabled = state.isPremium enabled = state.isPremium
) )
Box( StyledList(
modifier = Modifier.then( title = stringResource(R.string.volume_swipe_speed),
if (!state.isPremium) { description = stringResource(R.string.volume_swipe_speed_description)
Modifier.pointerInput(Unit) { ) {
awaitPointerEventScope { volumeSwipeSpeedOptions.forEach { (value, label) ->
while (true) { StyledListItem(
val event = awaitPointerEvent(PointerEventPass.Initial) name = label,
event.changes.forEach { it.consume() } selected = selectedVolumeSwipeSpeed == label,
} onClick = {
} selectedVolumeSwipeSpeed = label
}
} else Modifier)) {
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
viewModel.setControlCommandByte( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 1.toByte()
) )
}, }
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
} }
}
// if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") { // if (!hearingAidEnabled && XposedState.isAvailable) {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), style = TextStyle( // text = stringResource(R.string.apply_eq_to), style = TextStyle(
// fontSize = 14.sp, // fontSize = 14.sp,
@@ -637,208 +582,4 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
}
@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) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
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()
if (idx != previousIdx) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
parentHoveredIndex = idx
previousIdx = idx
}, onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
}
parentHoveredIndex = null
}, onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
})
},
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))
)
)
}
}
}
} }
@@ -18,14 +18,19 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -36,70 +41,57 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) { fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.customize_adaptive_audio) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
Spacer(modifier = Modifier.height(16.dp))
} }
val sliderValue = remember { val sliderValue = remember {
mutableFloatStateOf( mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
0
)?.toFloat() ?: 50f
)
} }
var job by remember { mutableStateOf<Job?>(null) } var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -125,6 +117,6 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
description = stringResource(R.string.adaptive_audio_description), description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium enabled = state.isPremium
) )
} Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -20,24 +20,33 @@ package me.kavishdevar.librepods.presentation.screens
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
@@ -49,12 +58,12 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -62,27 +71,41 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType 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.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.MaterialTypography
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppSettingsScreen( fun AppSettingsScreen(
navController: NavController, viewModel: AppSettingsViewModel = viewModel() viewModel: AppSettingsViewModel = viewModel(),
navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit,
navigateToReleaseNotesScreen: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -90,45 +113,86 @@ fun AppSettingsScreen(
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val contactBottomSheet = remember { mutableStateOf(false) }
title = stringResource(R.string.settings) val subjectState = remember { TextFieldState() }
) { topPadding, hazeState, bottomPadding -> val descriptionState = remember { TextFieldState() }
val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
if (!state.isPremium && state.connectionSuccessful) { if (!state.isPremium && state.connectionSuccessful) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.timeUntilFOSSPremiumExpiry > 0L) {
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = Color.White,
color = Color.White fontFamily = FontFamily(Font(R.font.sf_pro))
), )
) )
} }
} }
StyledToggle(
title = stringResource(R.string.appearance),
label = stringResource(R.string.use_material3e),
checked = state.m3eEnabled,
onCheckedChange = viewModel::setm3eEnabled,
enabled = state.isPremium
)
if (state.connectionSuccessful) { if (state.connectionSuccessful) {
StyledToggle( StyledToggle(
title = stringResource(R.string.widget), title = stringResource(R.string.widget),
@@ -139,46 +203,38 @@ fun AppSettingsScreen(
enabled = state.isPremium enabled = state.isPremium
) )
Text( StyledList(title = stringResource(R.string.popup_animations)) {
text = stringResource(R.string.conversational_awareness), style = TextStyle( StyledToggle(
fontSize = 14.sp, label = stringResource(R.string.show_bottom_sheet_popup),
fontWeight = FontWeight.Bold, description = stringResource(R.string.show_bottom_sheet_popup_description),
color = textColor.copy(alpha = 0.6f), checked = state.showBottomSheetPopup,
fontFamily = FontFamily(Font(R.font.sf_pro)) onCheckedChange = viewModel::setShowBottomSheetPopup,
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
) )
Spacer(modifier = Modifier.height(2.dp)) StyledToggle(
label = stringResource(R.string.show_island_popup),
Column( description = stringResource(R.string.show_island_popup_description),
modifier = Modifier checked = state.showIslandPopup,
.fillMaxWidth() onCheckedChange = viewModel::setShowIslandPopup,
.background(
backgroundColor, RoundedCornerShape(28.dp)
) )
.padding(vertical = 4.dp) }
) {
Spacer(modifier = Modifier.height(16.dp))
StyledList (title = stringResource(R.string.conversational_awareness)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music), label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description), description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled, checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume), label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description), description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled, checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false,
enabled = state.isPremium, enabled = state.isPremium,
) )
} }
@@ -209,16 +265,16 @@ fun AppSettingsScreen(
// if (!BuildConfig.PLAY_BUILD) { // if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp)) // Spacer(modifier = Modifier.height(16.dp))
// //
// NavigationButton( // StyledListItem(
// to = "", // to = "",
// title = stringResource(R.string.camera_control), // titleRes = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package), // name = stringResource(R.string.set_custom_camera_package),
// navController = navController, // navController = navController,
// onClick = { // onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true) // if (state.isPremium) viewModel.setShowCameraDialog(true)
// }, // },
// independent = true, // independent = true,
// description = stringResource(R.string.camera_control_app_description) // descriptionRes = stringResource(R.string.camera_control_app_description)
// ) // )
// } // }
@@ -234,159 +290,84 @@ fun AppSettingsScreen(
) )
} }
Text( StyledList(title = stringResource(R.string.takeover_airpods_state)) {
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( StyledToggle(
label = stringResource(R.string.takeover_disconnected), label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc), description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected, checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected, onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_idle), label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc), description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle, checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle, onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_music), label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc), description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic, checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic, onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_call), label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc), description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall, checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall, onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( StyledList(title = stringResource(R.string.takeover_phone_state)) {
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( StyledToggle(
label = stringResource(R.string.takeover_ringing_call), label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc), description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall, checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall, onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_media_start), label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc), description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart, checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart, onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false,
enabled = state.isPremium enabled = state.isPremium
) )
} }
Text(
text = stringResource(R.string.advanced_options), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
StyledToggle( StyledToggle(
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
label = stringResource(R.string.use_alternate_head_tracking_packets), label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description), description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets, checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true,
enabled = state.isPremium enabled = state.isPremium
) )
Spacer(modifier = Modifier.height(16.dp))
} else { } else {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text( Text(
text = stringResource(R.string.customizations_unavailable), text = stringResource(R.string.customizations_unavailable),
style = TextStyle( style = MaterialTypography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
) )
} }
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
if (BuildConfig.FLAVOR == "xposed") { val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
Spacer(modifier = Modifier.height(16.dp))
val restartBluetoothText =
stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle( StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${ label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource( stringResource(
@@ -398,255 +379,73 @@ fun AppSettingsScreen(
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled) viewModel.setVendorIdHook(enabled)
}, }
independent = true,
enabled = state.isPremium
) )
} }
if (!BuildConfig.PLAY_BUILD) { if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( StyledList {
to = "troubleshooting", StyledListItem(
name = stringResource(R.string.troubleshooting), name = stringResource(R.string.troubleshooting),
navController = navController, onClick = navigateToTroubleshooting,
independent = true,
description = stringResource(R.string.troubleshooting_description)
) )
} }
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( StyledList(title = stringResource(R.string.contact)) {
text = stringResource(R.string.contact), style = TextStyle( StyledListItem(
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)
)
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "",
name = stringResource(R.string.email), name = stringResource(R.string.email),
navController = navController, onClick = { contactBottomSheet.value = true },
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: <SUBJECT>")
putExtra(
Intent.EXTRA_TEXT,
"Describe your issue here:" +
"\n\n\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY} (${Build.PRODUCT})" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
},
independent = false
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.discord), name = stringResource(R.string.discord),
navController = navController,
onClick = { onClick = {
val intent = val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent) context.startActivity(intent)
}, },
independent = false
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.github_issues), name = stringResource(R.string.github_issues),
navController = navController,
onClick = { onClick = {
val intent = Intent( val appVersion =
Intent.ACTION_VIEW, Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
"https://github.com/kavishdevar/librepods/issues".toUri() val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
val appSource = Uri.encode(
when {
BuildConfig.PLAY_BUILD -> "Play"
else -> "GitHub"
}
) )
val url = "https://github.com/kavishdevar/librepods/issues/new" +
"?template=01-bug-report-android.yml" +
"&app-source=$appSource" +
"&app-version=$appVersion" +
"&device=$device" +
"&android-version=$androidVersion"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent) context.startActivity(intent)
}, },
independent = false
) )
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard() DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
Text( AppInfoCard(navigateToReleaseNotesScreen)
text = stringResource(R.string.about), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 24.dp)
)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Spacer(modifier = Modifier.height(4.dp))
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.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_NAME, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version_code), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.flavor), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.FLAVOR, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_type), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.BUILD_TYPE,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( StyledListItem(
to = "open_source_licenses",
name = stringResource(R.string.open_source_licenses), name = stringResource(R.string.open_source_licenses),
navController = navController, onClick = navigateToOpenSourceLicenses,
independent = true
) )
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
@@ -719,5 +518,96 @@ fun AppSettingsScreen(
}) })
} }
} }
StyledBottomSheet(
visible = contactBottomSheet.value,
onDismiss = { contactBottomSheet.value = false },
backdrop = backdrop
) { innerBackdrop, progress ->
val animatedPadding = lerp(16.dp, 2.dp, progress)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = animatedPadding)
.padding(bottom = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StyledIconButton(
icon = "\uDBC0\uDD84",
backdrop = innerBackdrop,
onClick = { contactBottomSheet.value = false }
)
Text (
text = stringResource(R.string.describe_your_issue),
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
StyledIconButton(
icon = "\uDBC0\uDE1F",
backdrop = innerBackdrop,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
onClick = {
contactBottomSheet.value = false
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
putExtra(
Intent.EXTRA_TEXT,
"${descriptionState.text}" +
"\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
subjectState.clearText()
descriptionState.clearText()
}
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledInputField(
inputState = subjectState,
focusRequester = subjectFocusRequester,
placeholder = stringResource(R.string.subject),
forceApple = true
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false,
forceApple = true
)
}
} }
} }
@@ -0,0 +1,97 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState()
val bytes =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try {
bytes[1] == 0x02.toByte()
} catch (e: Exception) {
false
}
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
val muteUnmuteText = stringResource(R.string.mute_unmute)
var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) }
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
val flippedValue = action != muteUnmuteText
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = pressOnceText,
selected = pressOnceIsAction,
onClick = {
singlePressAction = pressOnceText
onCallControlValueChanged(flippedValue)
}
)
StyledListItem(
name = pressTwiceText,
selected = !pressOnceIsAction,
onClick = {
singlePressAction = pressTwiceText
onCallControlValueChanged(!flippedValue)
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -18,94 +18,64 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.accessibilityservice.AccessibilityServiceInfo //@Composable
import android.content.ComponentName //fun CameraControlScreen(viewModel: AirPodsViewModel) {
import android.content.Context // val context = LocalContext.current
import android.content.Intent // val currentCameraAction by viewModel.cameraAction.collectAsState()
import android.provider.Settings //
import android.view.accessibility.AccessibilityManager // fun isAppListenerServiceEnabled(context: Context): Boolean {
import androidx.compose.foundation.layout.Arrangement // val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
import androidx.compose.foundation.layout.Column // val enabledServices =
import androidx.compose.foundation.layout.Spacer // am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
import androidx.compose.foundation.layout.fillMaxSize // val serviceComponent = ComponentName(context, AppListenerService::class.java)
import androidx.compose.foundation.layout.height // return enabledServices.any {
import androidx.compose.foundation.layout.padding // it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
import androidx.compose.runtime.Composable // it.resolveInfo.serviceInfo.name == serviceComponent.className
import androidx.compose.runtime.collectAsState // }
import androidx.compose.runtime.getValue // }
import androidx.compose.runtime.remember //
import androidx.compose.ui.Modifier // fun handleSelection(action: StemPressType?) {
import androidx.compose.ui.platform.LocalContext // if (action != null && !isAppListenerServiceEnabled(context)) {
import androidx.compose.ui.res.stringResource // context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
import androidx.compose.ui.unit.dp // } else {
import com.kyant.backdrop.backdrops.layerBackdrop // viewModel.setCameraAction(action)
import com.kyant.backdrop.backdrops.rememberLayerBackdrop // }
import me.kavishdevar.librepods.R // }
import me.kavishdevar.librepods.presentation.components.SelectItem //
import me.kavishdevar.librepods.presentation.components.StyledScaffold // val cameraOptions = remember(currentCameraAction) {
import me.kavishdevar.librepods.presentation.components.StyledSelectList // listOf(
import me.kavishdevar.librepods.services.AppListenerService // SelectItem(
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType // name = "Off",
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel // selected = currentCameraAction == null,
// onClick = { handleSelection(null) }
@Composable // ),
fun CameraControlScreen(viewModel: AirPodsViewModel) { // SelectItem(
val context = LocalContext.current // name = "Press once",
val currentCameraAction by viewModel.cameraAction.collectAsState() // selected = currentCameraAction == StemPressType.SINGLE_PRESS,
// onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
fun isAppListenerServiceEnabled(context: Context): Boolean { // ),
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager // SelectItem(
val enabledServices = // name = "Press and hold AirPods",
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) // selected = currentCameraAction == StemPressType.LONG_PRESS,
val serviceComponent = ComponentName(context, AppListenerService::class.java) // onClick = { handleSelection(StemPressType.LONG_PRESS) }
return enabledServices.any { // )
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && // )
it.resolveInfo.serviceInfo.name == serviceComponent.className // }
} //
} // val backdrop = rememberLayerBackdrop()
//
fun handleSelection(action: StemPressType?) { // StyledScaffold(
if (action != null && !isAppListenerServiceEnabled(context)) { // titleRes = stringResource(R.string.camera_control)
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) // ) { spacerHeight ->
} else { // Column(
viewModel.setCameraAction(action) // modifier = Modifier
} // .fillMaxSize()
} // .layerBackdrop(backdrop)
// .padding(horizontal = 16.dp),
val cameraOptions = remember(currentCameraAction) { // verticalArrangement = Arrangement.spacedBy(16.dp)
listOf( // ) {
SelectItem( // Spacer(modifier = Modifier.height(spacerHeight))
name = "Off", // StyledSelectList(items = cameraOptions)
selected = currentCameraAction == null, // }
onClick = { handleSelection(null) } // }
), //}
SelectItem(
name = "Press once",
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
),
SelectItem(
name = "Press and hold AirPods",
selected = currentCameraAction == StemPressType.LONG_PRESS,
onClick = { handleSelection(StemPressType.LONG_PRESS) }
)
)
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.camera_control)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledSelectList(items = cameraOptions)
}
}
}
@@ -1,523 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.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.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.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 = "􀈑",
backdrop = scaffoldBackdrop
)
}
),
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.navigationBarsPadding()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
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)
)
}
}
}
}
@@ -0,0 +1,762 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun EqualizerRoute(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setCustomEqEnabled = viewModel::setCustomEqEnabled,
setCustomEq = viewModel::setCustomEq
)
}
}
@OptIn(FlowPreview::class)
@Composable
fun EqualizerScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setCustomEqEnabled: (Boolean) -> Unit,
setCustomEq: (Int, Int, Int) -> Unit
) {
val customEq = state.customEq
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
val offsets = remember(state.customEq) {
listOf(
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
)
}
LaunchedEffect(offsets) {
snapshotFlow {
Triple(
offsets[0].floatValue,
offsets[1].floatValue,
offsets[2].floatValue
)
}
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
.collect { (lowF, midF, highF) ->
val low =
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val mid =
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
val high =
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
setCustomEq(low, mid, high)
}
}
Spacer(modifier = Modifier.height(topPadding))
val enabled = customEq.isEnabled()
StyledList {
StyledListItem(
name = stringResource(R.string.recommended),
selected = !enabled,
onClick = { setCustomEqEnabled(false) }
)
StyledListItem(
name = stringResource(R.string.custom),
selected = enabled,
onClick = { setCustomEqEnabled(true) }
)
}
Spacer(modifier = Modifier.height(12.dp))
Crossfade (
customEq.isEnabled()
) { visible ->
Column(
modifier = Modifier
.fillMaxWidth()
.visible(visible),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
EqualizerCard(
lowOffset = offsets[0],
midOffset = offsets[1],
highOffset = offsets[2]
)
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
StyledButton(
onClick = {
offsets[0].floatValue = 0f
offsets[1].floatValue = 0f
offsets[2].floatValue = 0f
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
isInteractive = false,
enabled = resetButtonEnabled.value
) {
Text(
text = stringResource(R.string.reset),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@Composable
fun EqualizerCard(
lowOffset: MutableState<Float>,
midOffset: MutableState<Float>,
highOffset: MutableState<Float>
) {
val height = 200.dp
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
) {
Spacer(modifier = Modifier.height(42.dp))
// Row(
// modifier = Modifier
// .fillMaxWidth()
// .padding(18.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(12.dp)
// ) {
// Box(
// modifier = Modifier
// .size(64.dp)
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
// )
// Column(
// modifier = Modifier
// .weight(1f),
// verticalArrangement = Arrangement.Center
// ) {
// Text(
// text = "Written into Changes",
// style = TextStyle(
// fontSize = 16.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Bold,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// Spacer(modifier = Modifier.height(4.dp))
// Text(
// text = "Avalon Emerson",
// style = TextStyle(
// fontSize = 14.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
// )
// )
// }
// val paused = remember { mutableStateOf(false) }
// Box(
// modifier = Modifier
// .size(48.dp)
// .background(Color(0x600091FF), CircleShape)
// .clickable(
// interactionSource = remember { MutableInteractionSource() },
// indication = null,
// ) {
// paused.value = !paused.value
// },
// contentAlignment = Alignment.Center
// ) {
// Crossfade(
// targetState = paused.value,
// label = "media_icon"
// ) { p ->
// Text(
// text = if (p) "􀊄" else "􀊆",
// style = TextStyle(
// fontSize = 24.sp,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// fontWeight = FontWeight.Normal,
// color = Color(0xFF0091FF),
// textAlign = TextAlign.Center
// )
// )
// }
// }
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888),
// modifier = Modifier
// .padding(horizontal = 20.dp)
// .padding(bottom = 16.dp)
// )
Box(
modifier = Modifier.fillMaxWidth()
) {
fun colorFromY(y: Float): Color {
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
val stops = listOf(
0.0f to Color(0xFFFFA300),
0.25f to Color(0xFFFCE600),
0.5f to Color(0xFF00FAAF),
0.75f to Color(0xFF00FAFF),
1.0f to Color(0xFF00B5FF)
)
val (start, end) = stops.zipWithNext()
.first { f <= it.second.first }
val c = (f - start.first) / (end.first - start.first)
return lerp(start.second, end.second, c)
}
fun pathBrush(
startY: Float,
endY: Float,
): Brush {
val stops = (0..20).map { i ->
val t = i / 20f
val y = lerp(startY, endY, t)
t to colorFromY(y)
}
return Brush.linearGradient(
colorStops = stops.toTypedArray()
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp)
) {
Row(
modifier = Modifier
.fillMaxSize()
) {
val dashCount = (height / 10.dp).toInt()
repeat(3) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
for (i in 1..(dashCount)) {
val t = i.toFloat() / dashCount
val centerDistance = abs(0.5f - t)
val alpha = 1f - (centerDistance * 2f)
Box(
modifier = Modifier
.height(9.dp)
.width(0.75.dp)
.background(
dashColor.copy(alpha),
RoundedCornerShape(28.dp)
)
)
}
}
}
}
}
val backgroundColor = MaterialTheme.colorScheme.surface
Canvas(
modifier = Modifier
.fillMaxSize()
) {
val canvasWidth = size.width
drawLine(
color = backgroundColor,
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(lowOffset.value),
start = Offset(
x = 0f,
y = lowOffset.value + maxOffset
),
end = Offset(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
),
strokeWidth = 8f
)
val lowToMidPath = Path()
lowToMidPath.moveTo(
x = 1 / 6f * canvasWidth,
y = lowOffset.value + maxOffset
)
lowToMidPath.cubicTo(
x1 = canvasWidth * 1 / 6f + 108.dp.value,
y1 = lowOffset.value + maxOffset,
x2 = canvasWidth * 0.5f - 108.dp.value,
y2 = midOffset.value + maxOffset,
x3 = canvasWidth * 0.5f,
y3 = midOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = lowToMidPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
lowOffset.value,
midOffset.value
),
path = lowToMidPath,
style = Stroke(width = 8f)
)
val midToHighPath = Path()
midToHighPath.moveTo(
x = 0.5f * canvasWidth,
y = midOffset.value + maxOffset
)
midToHighPath.cubicTo(
x1 = canvasWidth * 0.5f + 108.dp.value,
y1 = midOffset.value + maxOffset,
x2 = canvasWidth * 5 / 6f - 108.dp.value,
y2 = highOffset.value + maxOffset,
x3 = canvasWidth * 5 / 6f,
y3 = highOffset.value + maxOffset
)
drawPath(
color = backgroundColor,
path = midToHighPath,
style = Stroke(width = 10f)
)
drawPath(
brush = pathBrush(
midOffset.value,
highOffset.value
),
path = midToHighPath,
style = Stroke(width = 8f)
)
drawLine(
color = backgroundColor,
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 10f
)
drawLine(
color = colorFromY(highOffset.value),
start = Offset(
x = 5 / 6f * canvasWidth,
y = highOffset.value + maxOffset
),
end = Offset(
x = 1f * canvasWidth,
y = highOffset.value + maxOffset
),
strokeWidth = 8f
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Low".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "Mid".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
Box(
modifier = Modifier.weight(1f)
) {
Text(
text = "High".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
0.2f
),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(height)
.padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically
) {
for (i in 0..2) {
Row(
modifier = Modifier
.weight(1f),
horizontalArrangement = Arrangement.Center
) {
val pressed = remember { mutableStateOf(false) }
Box(
modifier = Modifier
.offset {
IntOffset(
x = 0,
y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt()
)
},
contentAlignment = Alignment.Center
) {
Crossfade(
pressed.value
) {
Box(
modifier = Modifier
.size(96.dp)
.then(
if (it) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { CircleShape },
highlight = {
Highlight.Ambient
},
onDrawSurface = {
drawCircle(
color = Color.White.copy(
0.2f
),
radius = size.height
)
drawCircle(
color = colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
style = Stroke(2.dp.value),
radius = size.height / 2
)
},
effects = {
lens(
refractionHeight = 32f.dp.value,
refractionAmount = size.height
)
}
)
} else Modifier
)
)
}
Box(
modifier = Modifier
.size(18.dp)
.background(
colorFromY(
when (i) {
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
else -> 0f
}
),
CircleShape
)
.border(
2.5.dp,
MaterialTheme.colorScheme.surfaceContainer,
CircleShape
)
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
when (i) {
0 -> {
lowOffset.value =
(lowOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
1 -> {
midOffset.value =
(midOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
2 -> {
highOffset.value =
(highOffset.value + delta).coerceIn(
-maxOffset,
maxOffset
)
}
}
},
onDragStarted = {
pressed.value = true
},
onDragStopped = {
pressed.value = false
}
)
)
}
}
}
}
}
}
}
}
@Preview(name = "Apple")
@Composable
fun EqualizerScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = demoState,
setCustomEqEnabled = { },
setCustomEq = {_, _, _ -> }
)
}
}
}
@Preview(name = "Material")
@Composable
fun EqualizerScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
val state = remember { mutableStateOf(demoState) }
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen(
state = state.value,
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
)
}
}
}
@@ -24,10 +24,6 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.util.Log
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -36,21 +32,26 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -66,52 +67,38 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color 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.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
viewModel.startHeadTracking() viewModel.startHeadTracking()
@@ -123,93 +110,64 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.head_tracking), val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
actionButtons = listOf( val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
{ scaffoldBackdrop -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
StyledIconButton(
onClick = {
if (!state.headTrackingActive) {
viewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
viewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (state.headTrackingActive) "􀊅" else "􀊃",
backdrop = scaffoldBackdrop
)
}
),
) { topPadding, hazeState, _ ->
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) } var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) } var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally 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(topPadding)) Spacer(modifier = Modifier.height(topPadding))
Column (
modifier = Modifier
.fillMaxWidth()
.layerBackdrop(backdrop)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
if (!state.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
StyledToggle( StyledToggle(
label = "Head Gestures", label = "Head Gestures",
checked = state.headGesturesEnabled, checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium, enabled = state.isPremium || state.headGesturesEnabled,
description = stringResource(R.string.head_gestures_details) description = stringResource(R.string.head_gestures_details),
header = true
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(
"Head Orientation",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
HeadVisualization()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@@ -222,7 +180,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
), ),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
) )
AccelerationPlot() Plot()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -246,7 +204,9 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
} }
}, },
backdrop = backdrop, backdrop = backdrop,
modifier = Modifier.fillMaxWidth(0.75f), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
maxScale = 0.05f maxScale = 0.05f
) { ) {
Text( Text(
@@ -255,7 +215,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor color = MaterialTheme.colorScheme.onSecondaryContainer
), ),
) )
} }
@@ -281,18 +241,15 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
gestureText = "" gestureText = ""
} }
} }
ParticleText( Text(
text = text, text = text,
style = TextStyle( style = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ),
onAnimationComplete = { color = MaterialTheme.colorScheme.onBackground
shouldExplode = false
},
) )
} else { } else {
Text( Text(
@@ -310,321 +267,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
} }
} }
} }
} Spacer(modifier = Modifier.height(bottomPadding))
}
}
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 @Composable
private fun HeadVisualization() { private fun Plot() {
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
),
shape = RoundedCornerShape(28.dp)
) {
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 = Paint().apply {
style = Paint.Style.FILL
shader = 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),
Shader.TileMode.CLAMP
)
}
drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = Paint().apply {
style = Paint.Style.FILL
shader = 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),
Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 30 else 60
}
drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = Paint().apply {
style = Paint.Style.FILL
shader = 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),
Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 15 else 30
}
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = Paint().apply {
style = Paint.Style.FILL
shader = 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),
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 = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
typeface = Typeface.create(
"SF Pro",
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 acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100 val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() } val points = remember { mutableStateListOf<Pair<Float, Float>>() }
@@ -646,11 +294,12 @@ private fun AccelerationPlot() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(300.dp), .height(300.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
),
shape = RoundedCornerShape(28.dp) shape = RoundedCornerShape(28.dp)
) { ) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -701,14 +350,14 @@ private fun AccelerationPlot() {
val x2 = (i + 1) * xScale val x2 = (i + 1) * xScale
drawLine( drawLine(
color = Color(0xFF007AFF), color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale), start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale), end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
drawLine( drawLine(
color = Color(0xFFFF3B30), color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale), start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale), end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
@@ -731,7 +380,7 @@ private fun AccelerationPlot() {
val legendY = 15.dp.toPx() val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2 val textOffsetY = legendY + 5.dp.toPx() / 2
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
@@ -741,7 +390,7 @@ private fun AccelerationPlot() {
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
} }
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
@@ -20,50 +20,46 @@ package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -71,67 +67,42 @@ private const val TAG = "HearingAidAdjustments"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val debounceJob = remember { mutableStateOf<Job?>(null) }
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 amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) } val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) } val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) } val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) } val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val initialLoadComplete = remember { mutableStateOf(false) } val initialized = rememberSaveable { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) } val hearingAidSettings = remember { mutableStateOf(
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, leftAmplification = 0f,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, rightAmplification = 0f,
leftTone = toneSliderValue.floatValue, leftTone = 0f,
rightTone = toneSliderValue.floatValue, rightTone = 0f,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = false,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = false,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, leftAmbientNoiseReduction = 0f,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, rightAmbientNoiseReduction = 0f,
netAmplification = amplificationSliderValue.floatValue, netAmplification = 0f,
balance = balanceSliderValue.floatValue, balance = 0f,
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = 0f
) )
) ) }
}
val hearingAidATTListener = remember { LaunchedEffect(state.hearingAidData) {
object : (ByteArray) -> Unit { parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
amplificationSliderValue.floatValue = parsed.netAmplification amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone toneSliderValue.floatValue = parsed.leftTone
@@ -140,25 +111,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
leftEQ.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf() rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification") initialized.value = true
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
} }
} }
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) { LaunchedEffect(
if (!initialLoadComplete.value) { amplificationSliderValue.floatValue,
Log.d(TAG, "Initial device load not complete - skipping send") balanceSliderValue.floatValue,
return@LaunchedEffect toneSliderValue.floatValue,
} conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
if (!initialReadSucceeded.value) { ownVoiceAmplification.floatValue
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") ) {
return@LaunchedEffect if (!initialized.value) return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
@@ -175,53 +140,22 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
} }
LaunchedEffect(Unit) { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Log.d(TAG, "Connecting to ATT...") val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
try { val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null Column(
for (attempt in 1..3) { modifier = Modifier
initialReadAttempts.intValue = attempt .fillMaxSize()
try { .background(MaterialTheme.colorScheme.surfaceContainer)
val data = attManager.read(ATTHandles.HEARING_AID) .verticalScroll(verticalScrollState)
parsedSettings = parseHearingAidSettingsResponse(data = data) .padding(horizontal = 16.dp),
if (parsedSettings != null) { verticalArrangement = Arrangement.spacedBy(16.dp)
Log.d(TAG, "Parsed settings on attempt $attempt") ) {
break Spacer(modifier = Modifier.height(topPadding))
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
@@ -235,7 +169,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
independent = true, independent = true,
) )
StyledToggle( StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
@@ -284,9 +217,9 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value, checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it }, onCheckedChange = { conversationBoostEnabled.value = it },
independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description)
) )
}
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -25,16 +25,17 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -43,7 +44,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -52,11 +52,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -66,9 +63,11 @@ import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -78,14 +77,11 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() } val backdrop = rememberLayerBackdrop()
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) } val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
@@ -96,22 +92,20 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.hearing_aid), val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
snackbarHostState = snackbarHostState, val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
// val mediaAssistEnabled = remember { mutableStateOf(false) } // val mediaAssistEnabled = remember { mutableStateOf(false) }
@@ -137,46 +131,18 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
// // TODO // // TODO
// } // }
Text( StyledList (title = stringResource(R.string.hearing_aid)) {
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( StyledToggle(
label = stringResource(R.string.hearing_aid), label = stringResource(R.string.hearing_aid),
checked = hearingAidEnabled.value, checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it }, onCheckedChange = { hearingAidEnabled.value = it },
independent = false
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid_adjustments",
name = stringResource(R.string.adjustments), name = stringResource(R.string.adjustments),
navController = navController, onClick = onNavigateHearingAidAdjustments,
independent = false
) )
} }
Text( Text(
text = stringResource(R.string.hearing_aid_description), text = stringResource(R.string.hearing_aid_description),
style = TextStyle( style = TextStyle(
@@ -189,21 +155,19 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( StyledListItem(
to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test), name = stringResource(R.string.update_hearing_test),
navController = navController, onClick = onNavigateHearingTest,
independent = true
) )
// not implemented yet // not implemented yet
// StyledToggle( // StyledToggle(
// title = stringResource(R.string.media_assist), // titleRes = stringResource(R.string.media_assist),
// label = stringResource(R.string.media_assist), // label = stringResource(R.string.media_assist),
// checkedState = mediaAssistEnabled, // checkedState = mediaAssistEnabled,
// independent = true, // independent = true,
// description = stringResource(R.string.media_assist_description) // descriptionRes = stringResource(R.string.media_assist_description)
// ) // )
// Spacer(modifier = Modifier.height(8.dp)) // Spacer(modifier = Modifier.height(8.dp))
@@ -235,7 +199,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Enable Hearing Aid", title = "Enable Hearing Aid",
@@ -259,6 +223,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
return@launch return@launch
} }
val parsed = parseTransparencySettingsResponse(state.hearingAidData) val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
@@ -270,7 +238,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
hearingAidEnabled.value = false hearingAidEnabled.value = false
showDialog.value = false showDialog.value = false
}, },
hazeState = hazeStateS.value, backdrop = backdrop
// backdrop = backdrop
) )
} }
@@ -18,72 +18,70 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
StyledScaffold(
title = stringResource(R.string.hearing_protection), val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
) { spacerHeight -> val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
Spacer(modifier = Modifier.height(16.dp))
} }
if (state.vendorIdHook) { if (state.vendorIdHook) {
StyledToggle( StyledToggle(
title = stringResource(R.string.environmental_noise), title = stringResource(R.string.environmental_noise),
@@ -115,6 +113,6 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavContr
}, },
enabled = state.isPremium enabled = state.isPremium
) )
} Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -0,0 +1,35 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer),
contentAlignment = Alignment.Center
) {
CircularWavyProgressIndicator(
modifier = Modifier
.size(120.dp)
)
}
}
@Preview
@Composable
fun LoadingScreenPreview() {
LibrePodsTheme {
LoadingScreen()
}
}
@@ -0,0 +1,101 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun MicrophoneSettingsRoute(
viewModel: AirPodsViewModel
) {
val state by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
MicrophoneSettingsScreen(
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = {
viewModel.setControlCommandInt(id, it)
}
)
}
}
@Composable
fun MicrophoneSettingsScreen(
selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
onMicrophoneSettingsChanged: (Int) -> Unit
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
StyledList {
StyledListItem(
name = stringResource(R.string.microphone_automatic),
selected = selectedMode == 0,
onClick = { onMicrophoneSettingsChanged(0) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_right),
selected = selectedMode == 1,
onClick = { onMicrophoneSettingsChanged(1) }
)
StyledListItem(
name = stringResource(R.string.microphone_always_left),
selected = selectedMode == 2,
onClick = { onMicrophoneSettingsChanged(2) }
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -19,21 +19,24 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
@@ -41,7 +44,8 @@ import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -50,21 +54,22 @@ private var debounceJob: Job? = null
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun OpenSourceLicensesScreen(navController: NavController) { fun OpenSourceLicensesScreen() {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.open_source_licenses) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val context = LocalContext.current val context = LocalContext.current
val libraries by produceLibraries { val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries) context.resources.openRawResource(R.raw.aboutlibraries)
@@ -77,6 +82,6 @@ fun OpenSourceLicensesScreen(navController: NavController) {
.padding(0.dp) .padding(0.dp)
.fillMaxSize() .fillMaxSize()
) )
} Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -20,47 +20,43 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.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.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -68,10 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) { fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
@@ -82,222 +75,159 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val context = LocalContext.current val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action" val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref") val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
val backdrop = rememberLayerBackdrop() val scrollState = rememberScrollState()
StyledScaffold(
title = name
) { spacerHeight ->
Column ( Column (
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState)
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val actionItems = listOf(
SelectItem( StyledList {
StyledListItem(
name = stringResource(R.string.noise_control), name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = { onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES viewModel.setLongPressAction(
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) } name,
StemAction.CYCLE_NOISE_CONTROL_MODES
)
} }
), )
SelectItem(
StyledListItem(
name = stringResource(R.string.digital_assistant), name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT, selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = { onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT viewModel.setLongPressAction(
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) } name,
StemAction.DIGITAL_ASSISTANT
)
}, },
enabled = state.isPremium enabled = state.isPremium
) )
) }
StyledSelectList(items = actionItems)
if (!state.isPremium) { if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(horizontal = 18.dp)
)
Spacer(modifier = Modifier.height(8.dp)) val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { StyledList(
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION title = stringResource(R.string.noise_control),
}?.value?.takeIf { it.isNotEmpty() }?.get(0) description = stringResource(R.string.press_and_hold_noise_control_description)
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue") ) {
val allowOff = offListeningModeValue == 1.toByte() if (state.offListeningMode) {
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff") StyledListItem(
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
?.get(0)?.toInt()
?: sharedPreferences.getInt("long_press_byte", 0b0101)
var currentByte by remember { mutableIntStateOf(initialByte) }
val listeningModeItems = mutableListOf<SelectItem>()
if (allowOff) {
listeningModeItems.add(
SelectItem(
name = stringResource(R.string.off), name = stringResource(R.string.off),
description = stringResource(R.string.listening_mode_off_description), description = stringResource(R.string.listening_mode_off_description),
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0, selected = (currentByte and 0x01) != 0,
onClick = { onClick = {
val bit = 0x01 viewModel.toggleListeningMode(0x01)
val newValue = if ((currentByte and bit) != 0) { },
val temp = currentByte and bit.inv() orientation = ListItemOrientation.Vertical,
if (countEnabledModes(temp) >= 2) temp else currentByte leadingContent = {
} else { Icon(
currentByte or bit painter = painterResource(R.drawable.noise_cancellation),
} contentDescription = "Icon",
viewModel.setControlCommandByte( tint = MaterialTheme.colorScheme.primary,
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, modifier = Modifier
newValue.toByte() .height(42.dp)
) .wrapContentWidth()
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
)
) )
} }
listeningModeItems.addAll(listOf( )
SelectItem( }
StyledListItem(
name = stringResource(R.string.transparency), name = stringResource(R.string.transparency),
description = stringResource(R.string.listening_mode_transparency_description), description = stringResource(R.string.listening_mode_transparency_description),
iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0, selected = (currentByte and 0x04) != 0,
onClick = { onClick = {
val bit = 0x04 viewModel.toggleListeningMode(0x04)
val newValue = if ((currentByte and bit) != 0) { },
val temp = currentByte and bit.inv() orientation = ListItemOrientation.Vertical,
if (countEnabledModes(temp) >= 2) temp else currentByte leadingContent = {
} else { Icon(
currentByte or bit painter = painterResource(R.drawable.transparency),
} contentDescription = "Icon",
viewModel.setControlCommandByte( tint = MaterialTheme.colorScheme.primary,
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, modifier = Modifier
newValue.toByte() .height(42.dp)
.wrapContentWidth()
) )
sharedPreferences.edit {
putInt("long_press_byte", newValue)
} }
currentByte = newValue )
}
), StyledListItem(
SelectItem(
name = stringResource(R.string.adaptive), name = stringResource(R.string.adaptive),
description = stringResource(R.string.listening_mode_adaptive_description), description = stringResource(R.string.listening_mode_adaptive_description),
iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0, selected = (currentByte and 0x08) != 0,
onClick = { onClick = {
val bit = 0x08 viewModel.toggleListeningMode(0x08)
val newValue = if ((currentByte and bit) != 0) { },
val temp = currentByte and bit.inv() orientation = ListItemOrientation.Vertical,
if (countEnabledModes(temp) >= 2) temp else currentByte leadingContent = {
} else { Icon(
currentByte or bit painter = painterResource(R.drawable.adaptive),
} contentDescription = "Icon",
viewModel.setControlCommandByte( tint = MaterialTheme.colorScheme.primary,
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, modifier = Modifier
newValue.toByte() .height(42.dp)
.wrapContentWidth()
) )
sharedPreferences.edit {
putInt("long_press_byte", newValue)
} }
currentByte = newValue )
}
), StyledListItem(
SelectItem(
name = stringResource(R.string.noise_cancellation), name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_noise_cancellation_description), description = stringResource(R.string.listening_mode_noise_cancellation_description),
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0, selected = (currentByte and 0x02) != 0,
onClick = { onClick = {
val bit = 0x02 viewModel.toggleListeningMode(0x02)
val newValue = if ((currentByte and bit) != 0) { },
val temp = currentByte and bit.inv() orientation = ListItemOrientation.Vertical,
if (countEnabledModes(temp) >= 2) temp else currentByte leadingContent = {
} else { Icon(
currentByte or bit painter = painterResource(R.drawable.noise_cancellation),
} contentDescription = "Icon",
viewModel.setControlCommandByte( tint = MaterialTheme.colorScheme.primary,
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
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 modifier = Modifier
.padding(horizontal = 18.dp) .height(42.dp)
.wrapContentWidth()
)
}
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
}
fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
return count
} }
@@ -19,463 +19,161 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.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.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.navigation.Screen
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable @Composable
fun PurchaseScreen( fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(), viewModel: PurchaseViewModel = viewModel(),
navController: NavController backStack: SnapshotStateList<Screen>
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.unlock_advanced_features) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
LaunchedEffect(state.isPremium) { LaunchedEffect(state.isPremium) {
if (state.isPremium) { if (state.isPremium) {
navController.popBackStack() if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
} }
} }
if (!state.isPremium) { if (!state.isPremium) {
Box( StyledList(title = stringResource(R.string.free_features)) {
modifier = Modifier StyledListItem(
.background(backgroundColor) name = stringResource(R.string.ear_detection),
.padding(horizontal = 16.dp, vertical = 4.dp) description = stringResource(R.string.ear_detection_description),
) { enabled = false,
Text( orientation = ListItemOrientation.Vertical
text = stringResource(R.string.free_features),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
StyledListItem(
name = stringResource(R.string.battery),
description = stringResource(R.string.battery_description),
enabled = false,
orientation = ListItemOrientation.Vertical
) )
}
Column( StyledListItem(
modifier = Modifier name = stringResource(R.string.noise_control),
.fillMaxWidth() description = stringResource(R.string.noise_control_description),
.background(cardBackgroundColor, RoundedCornerShape(28.dp)) enabled = false,
.padding(horizontal = 8.dp, vertical = 16.dp), orientation = ListItemOrientation.Vertical
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.ear_detection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
) )
if (XposedState.isAvailable) {
StyledListItem(
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
description = stringResource(R.string.hearing_aid_description)
.substringBefore("\n\n"),
enabled = false,
orientation = ListItemOrientation.Vertical
) )
Text(
text = stringResource(R.string.ear_detection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.battery),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.battery_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.noise_control_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
if (BuildConfig.FLAVOR == "xposed") {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
R.string.requires_xposed
) + ")",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
} }
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
.background(backgroundColor) StyledListItem(
.padding(horizontal = 16.dp, vertical = 4.dp) name = stringResource(R.string.conversational_awareness),
) { description = stringResource(R.string.conversational_awareness_description),
Text( enabled = false,
text = stringResource(R.string.advanced_features), orientation = ListItemOrientation.Vertical
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.conversational_awareness_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.head_gestures),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.advanced_device_settings),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.advanced_device_settings_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.automatic_connection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.automatic_connection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.customizations),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.customizations_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.support_the_development),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.support_development_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.feature_availability_disclaimer),
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
textAlign = TextAlign.Center
),
) )
StyledListItem(
name = stringResource(R.string.digital_assistant_on_long_press),
description = stringResource(R.string.digital_assistant_on_long_press_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.head_gestures),
description = stringResource(R.string.head_gestures_details),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.advanced_device_settings),
description = stringResource(R.string.advanced_device_settings_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.automatic_connection),
description = stringResource(R.string.automatic_connection_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.customizations),
description = stringResource(R.string.customizations_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
StyledListItem(
name = stringResource(R.string.support_the_development),
description = stringResource(R.string.support_development_description),
enabled = false,
orientation = ListItemOrientation.Vertical
)
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -486,17 +184,13 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) surfaceColor = MaterialTheme.colorScheme.primary,
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) materialButtonStyle = MaterialButtonStyle.Filled
) { ) {
Text( Text(
stringResource(R.string.buy_price, state.price), stringResource(R.string.buy_price, state.price),
style = TextStyle( style = MaterialTheme.typography.bodyMediumEmphasized,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
) )
} }
@@ -509,20 +203,15 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
isInteractive = false isInteractive = false,
materialButtonStyle = MaterialButtonStyle.Outlined
) { ) {
Text( Text(
stringResource(R.string.restore_purchases), stringResource(R.string.restore_purchases),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -0,0 +1,471 @@
package me.kavishdevar.librepods.presentation.screens
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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.carousel.CarouselDefaults
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
import androidx.compose.material3.toPath
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.Matrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.updates.UpdateItem
import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReleaseNotesScreen(
updates: List<UpdateItem>,
releaseNotesShown: () -> Unit
) {
val state = rememberCarouselState(
initialItem = 0,
itemCount = { updates.size + 1 }
)
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
LibrePodsTheme(m3eEnabled = true) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.what_s_new),
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
val versionName = BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")
val url = "https://github.com/kavishdevar/librepods/releases/v$versionName"
val fullText = "${stringResource(R.string.version)} $versionName"
val textColor = MaterialTheme.colorScheme.primary
val annotatedString = buildAnnotatedString {
append(fullText)
addLink(
url = LinkAnnotation.Url(
url = url,
styles = TextLinkStyles(
style = SpanStyle(color = textColor)
)
),
start = 0,
end = fullText.length
)
}
Text(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.tertiary,
textDecoration = TextDecoration.Underline
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalUncontainedCarousel(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = state,
itemSpacing = 16.dp,
contentPadding = PaddingValues(horizontal = 16.dp),
itemWidth = LocalWindowInfo.current.containerDpSize.width - 64.dp,
flingBehavior = CarouselDefaults.singleAdvanceFlingBehavior(state)
) { index ->
val shape = rememberMaskShape(RoundedCornerShape(48.dp))
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = shape,
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (index != updates.size) {
val updateItem = updates[index]
val deviceBorderColor = MaterialTheme.colorScheme.tertiary
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.heightIn(max = 700.dp)
.align(Alignment.TopCenter)
.drawWithCache {
val path = createTopWavyRectPath(
width = size.width,
height = size.height,
cornerRadius = 52.dp.toPx(),
amplitude = 4.dp.toPx(),
wavelength = 36.dp.toPx()
)
onDrawWithContent {
drawContent()
drawPath(
path,
color = deviceBorderColor,
style = Stroke(
width = 4.dp.toPx()
)
)
}
}
) {
Box(
modifier = Modifier
.padding(2.dp)
.clip(RoundedCornerShape(50.dp))
) {
CompositionLocalProvider(
LocalDensity provides Density(
density = LocalDensity.current.density * 0.8f,
fontScale = LocalDensity.current.fontScale
),
LocalDesignSystem provides if (m3eEnabled) DesignSystem.Material else DesignSystem.Apple
) {
updateItem.demoComposeable()
}
}
}
Box(
modifier = Modifier
.padding(12.dp)
.aspectRatio(1f)
.background(
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f),
MaterialShapes.Arch.toShape()
)
.align(Alignment.BottomCenter)
) {
Column(
modifier = Modifier
.padding(32.dp)
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(updateItem.titleRes),
style = MaterialTheme.typography.displayMediumEmphasized,
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center
)
Text(
text = stringResource(updateItem.descriptionRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center
)
}
}
} else {
var pressed by remember { mutableStateOf(false) }
val morph = remember {
Morph(
MaterialShapes.Cookie7Sided.normalized(),
MaterialShapes.SoftBurst.normalized()
)
}
val morphProgress by animateFloatAsState(
targetValue = if (pressed) 1f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "morph",
)
val rotationSpeed by animateFloatAsState(
targetValue = if (pressed) 0f else 1f,
animationSpec = tween(1200),
label = "rotationSpeed"
)
var rotation by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
var lastFrame = withFrameNanos { it }
while (true) {
val frame = withFrameNanos { it }
val dt = (frame - lastFrame) / 1_000_000_000f
lastFrame = frame
rotation += 60f * rotationSpeed * dt
}
}
val path = remember { Path() }
val matrix = remember { Matrix() }
val tertiary = MaterialTheme.colorScheme.tertiary
Box(
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
}
)
}
) {
Box(
modifier = Modifier
.size(260.dp)
.align(Alignment.Center)
.pointerInput(Unit) {
detectTapGestures(
onTap = { releaseNotesShown() },
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
}
)
}
.drawBehind {
val shapePath = morph.toPath(
progress = morphProgress,
path = path
)
val bounds = shapePath.getBounds()
val scale = min(
size.width / bounds.width,
size.height / bounds.height
) * 0.9f
matrix.reset()
matrix.scale(scale, scale)
shapePath.transform(matrix)
shapePath.translate(
size.center -
shapePath.getBounds().center
)
rotate(rotation) {
drawPath(
path = shapePath,
color = tertiary
)
}
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowForward,
contentDescription = null,
modifier = Modifier.size(100.dp),
tint = MaterialTheme.colorScheme.onTertiary
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@Preview(uiMode = UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL)
@Preview(uiMode = UI_MODE_NIGHT_NO, wallpaper = RED_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL)
@Composable
fun ReleaseNotesScreenPreview() {
LibrePodsTheme(
m3eEnabled = false
) {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = { }
)
}
}
// ai gen'd helper
fun createTopWavyRectPath(
width: Float,
height: Float,
cornerRadius: Float,
amplitude: Float,
wavelength: Float
): Path {
return Path().apply {
moveTo(cornerRadius, 0f)
var x = cornerRadius
while (x < width - cornerRadius - wavelength) {
quadraticTo(
x + wavelength / 4f,
-amplitude,
x + wavelength / 2f,
0f
)
quadraticTo(
x + wavelength * 3f / 4f,
amplitude,
x + wavelength,
0f
)
x += wavelength
}
arcTo(
rect = Rect(
left = width - 2 * cornerRadius,
top = 0f,
right = width,
bottom = 2 * cornerRadius
),
startAngleDegrees = -90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(width, height - cornerRadius)
arcTo(
rect = Rect(
left = width - 2 * cornerRadius,
top = height - 2 * cornerRadius,
right = width,
bottom = height
),
startAngleDegrees = 0f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(cornerRadius, height)
arcTo(
rect = Rect(
left = 0f,
top = height - 2 * cornerRadius,
right = 2 * cornerRadius,
bottom = height
),
startAngleDegrees = 90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(0f, cornerRadius)
arcTo(
rect = Rect(
left = 0f,
top = 0f,
right = 2 * cornerRadius,
bottom = 2 * cornerRadius
),
startAngleDegrees = 180f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
close()
}
}
@@ -22,43 +22,31 @@ package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.content.Context
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -67,86 +55,40 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun RenameScreen(viewModel: AirPodsViewModel) { fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
keyboardController?.show() keyboardController?.show()
name.value = name.value.copy(selection = TextRange(name.value.text.length))
} }
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.name), val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val name = sharedPreferences.getString("name", "")?: ""
val textColor = if (isDarkTheme) Color.White else Color.Black val textFieldState = rememberTextFieldState(initialText = name)
val cursorColor = if (isDarkTheme) Color.White else Color.Black
Row( LaunchedEffect(textFieldState.text) {
verticalAlignment = Alignment.CenterVertically, sharedPreferences.edit {putString("name", textFieldState.text as String?)}
modifier = Modifier viewModel.setName(textFieldState.text.toString())
.fillMaxWidth() }
.height(58.dp)
.background( StyledInputField(
backgroundColor, textFieldState,
RoundedCornerShape(28.dp) focusRequester
) )
.padding(horizontal = 16.dp, vertical = 8.dp)
) { Spacer(modifier = Modifier.height(bottomPadding))
BasicTextField(
value = name.value,
onValueChange = {
name.value = it
sharedPreferences.edit {putString("name", it.text)}
viewModel.setName(it.text)
},
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
singleLine = true,
cursorBrush = SolidColor(cursorColor),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
innerTextField()
}
IconButton(
onClick = {
name.value = TextFieldValue("")
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
),
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
}
} }
} }
@@ -28,16 +28,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -46,9 +51,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
@@ -60,21 +66,16 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.data.TransparencySettings import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings" private const val TAG = "TransparencySettings"
@@ -92,19 +93,16 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.customize_transparency_mode) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
){ topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) } val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) } val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) } val eq = rememberSaveable(
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialLoadComplete = remember { mutableStateOf(false) } val initialized = rememberSaveable { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val transparencySettings = remember { val transparencySettings = remember {
mutableStateOf( mutableStateOf(
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
toneSliderValue.floatValue, toneSliderValue.floatValue,
conversationBoostEnabled.value, conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue, ambientNoiseReductionSliderValue.floatValue,
eq.value, eq.value
initialLoadComplete.value,
initialReadSucceeded.value
) { ) {
if (!initialLoadComplete.value) { if (!initialized.value) return@LaunchedEffect
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
transparencySettings.value = TransparencySettings( transparencySettings.value = TransparencySettings(
enabled = enabled.value, enabled = enabled.value,
leftEQ = eq.value, leftEQ = eq.value,
@@ -189,38 +180,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
} }
LaunchedEffect(Unit) { LaunchedEffect(state.transparencyData) {
Log.d(TAG, "Connecting to ATT...") val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
try {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: TransparencySettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings") Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification amplificationSliderValue.floatValue = parsedSettings.netAmplification
@@ -229,26 +190,16 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
ambientNoiseReductionSliderValue.floatValue = ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf() eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
} }
initialized.value = true
} }
if (state.vendorIdHook) { if (state.vendorIdHook) {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checked = enabled.value, checked = enabled.value,
independent = true,
description = stringResource(R.string.customize_transparency_mode_description), description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it } onCheckedChange = { enabled.value = it }
) )
@@ -305,7 +256,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value, checked = conversationBoostEnabled.value,
independent = true,
description = stringResource(R.string.conversation_boost_description), description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it } onCheckedChange = { conversationBoostEnabled.value = it }
) )
@@ -412,5 +362,4 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -38,10 +38,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -84,17 +88,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -118,7 +119,7 @@ fun CustomIconButton(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun TroubleshootingScreen(navController: NavController) { fun TroubleshootingScreen() {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -214,15 +215,15 @@ fun TroubleshootingScreen(navController: NavController) {
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
StyledScaffold( val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
title = stringResource(R.string.troubleshooting) val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
){ topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
@@ -745,7 +746,8 @@ fun TroubleshootingScreen(navController: NavController) {
} }
) )
} }
}
Spacer(modifier = Modifier.height(bottomPadding))
} }
if (showBottomSheet) { if (showBottomSheet) {
@@ -19,111 +19,133 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private const val TAG = "UpdateHearingTestScreen"
private const val TAG = "HearingAidAdjustments"
@Composable @Composable
fun UpdateHearingTestScreen() { fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState() val state by viewModel.uiState.collectAsState()
val attManager = ServiceManager.getService()?.attManager val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
if (attManager == null) { val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
Text( val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .background(MaterialTheme.colorScheme.surfaceContainer)
textAlign = TextAlign.Center ) {
UpdateHearingTestScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
) )
return
} }
}
@Composable
fun UpdateHearingTestScreen(
state: AirPodsUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
) {
val verticalScrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
Text( Text(
text = stringResource(R.string.hearing_test_value_instruction), text = stringResource(R.string.hearing_test_value_instruction),
modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.labelMedium,
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
val tone = remember { mutableFloatStateOf(0.5f) } val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) } val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) } val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) } val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) } val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) } val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) } val leftEQ = rememberSaveable(
val rightEQ = remember { mutableStateOf(FloatArray(8)) } saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val initialLoadComplete = remember { mutableStateOf(false) } val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialReadSucceeded = remember { mutableStateOf(false) } val initialized = rememberSaveable { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember { val hearingAidSettings = remember {
mutableStateOf( mutableStateOf(
@@ -145,10 +167,8 @@ fun UpdateHearingTestScreen() {
) )
} }
val hearingAidATTListener = remember { LaunchedEffect(state.hearingAidData) {
object : (ByteArray) -> Unit { val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) { if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf() rightEQ.value = parsed.rightEQ.copyOf()
@@ -158,46 +178,24 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
Log.w(TAG, "Failed to parse hearing aid settings from notification") Log.w(TAG, "Failed to parse hearing aid settings from notification")
} }
} }
}
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect( LaunchedEffect(
leftEQ.value, leftEQ.value,
rightEQ.value, rightEQ.value,
conversationBoostEnabled.value, conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue, leftAmplification.floatValue,
rightAmplification.floatValue, rightAmplification.floatValue,
tone.floatValue, tone.floatValue,
ambientNoiseReduction.floatValue, ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue ownVoiceAmplification.floatValue
) { ) {
if (!initialLoadComplete.value) { if (!initialized.value) return@LaunchedEffect
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
@@ -214,55 +212,7 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
} }
val frequencies = val frequencies =
@@ -277,21 +227,13 @@ fun UpdateHearingTestScreen() {
text = stringResource(R.string.left), text = stringResource(R.string.left),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = TextStyle( style = MaterialTheme.typography.labelMediumEmphasized
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
) )
Text( Text(
text = stringResource(R.string.right), text = stringResource(R.string.right),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = TextStyle( style = MaterialTheme.typography.labelMediumEmphasized
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
) )
} }
@@ -306,11 +248,7 @@ fun UpdateHearingTestScreen() {
.width(60.dp) .width(60.dp)
.align(Alignment.CenterVertically), .align(Alignment.CenterVertically),
textAlign = TextAlign.End, textAlign = TextAlign.End,
style = TextStyle( style = MaterialTheme.typography.labelSmall
color = textColor,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
) )
OutlinedTextField( OutlinedTextField(
value = leftEQ.value[index].toString(), value = leftEQ.value[index].toString(),
@@ -354,5 +292,41 @@ fun UpdateHearingTestScreen() {
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
@Preview(name = "Apple")
@Composable
fun UpdateHearingTestScreenPreviewApple() {
LibrePodsTheme(
m3eEnabled = false
) {
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
} }
} }
@Preview(name = "Material")
@Composable
fun UpdateHearingTestScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@@ -19,162 +19,63 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun VersionScreen(viewModel: AirPodsViewModel) { fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop() val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
StyledScaffold(
title = stringResource(R.string.version)
) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop) .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
Box( StyledList(title = stringResource(R.string.version)) {
modifier = Modifier StyledListItem(
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) name = stringResource(R.string.version) + " 1",
.padding(horizontal = 16.dp, vertical = 4.dp) description = state.version1,
){ enabled = false
Text(
text = stringResource(R.string.version),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
)
}
Column( StyledListItem(
modifier = Modifier name = stringResource(R.string.version) + " 2",
.clip(RoundedCornerShape(28.dp)) description = state.version2,
.fillMaxWidth() enabled = false
.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 = state.version1,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
StyledListItem(
name = stringResource(R.string.version) + " 3",
description = state.version3,
enabled = false
) )
} }
HorizontalDivider( Spacer(modifier = Modifier.height(bottomPadding))
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 = state.version2,
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 = state.version3,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} }
} }
@@ -0,0 +1,57 @@
package me.kavishdevar.librepods.presentation.screens.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledListItem
@Composable
fun NotSupportedPage(
bypassCompatibilityCheck: () -> Unit
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
style = MaterialTheme.typography.bodyMedium,
)
DeviceInfoCard()
AppInfoCard()
StyledListItem(
name = stringResource(R.string.bypass_compatibility_check),
onClick = bypassCompatibilityCheck
)
}
}
}
@@ -0,0 +1,222 @@
package me.kavishdevar.librepods.presentation.screens.onboarding
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.carousel.HorizontalUncontainedCarousel
import androidx.compose.material3.carousel.rememberCarouselState
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.bypassDeviceCheck
import me.kavishdevar.librepods.utils.isSupported
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
onOnboardingComplete: () -> Unit,
) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isSupported = isSupported(sharedPreferences) || XposedState.bluetoothScopeEnabled
val state = rememberCarouselState(
initialItem = 0,
itemCount = { 4 }
)
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val titles = listOf(
null,
stringResource(R.string.privacy_policy),
stringResource(R.string.not_supported),
stringResource(R.string.permissions),
)
val animationScope = rememberCoroutineScope()
BackHandler {
animationScope.launch {
if (state.canScrollBackward) {
val targetItem = if (isSupported && state.currentItem == 3) 1 else state.currentItem - 1
state.animateScrollToItem(targetItem)
}
}
}
LibrePodsTheme(
m3eEnabled = true
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
HorizontalUncontainedCarousel(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(12.dp),
state = state,
itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp,
userScrollEnabled = false
) { index ->
val shape = rememberMaskShape(RoundedCornerShape(52.dp))
Surface(
shape = shape,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(52.dp))
) {
Column(
modifier = Modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
titles[index]?.let {
Text(
text = it,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
}
when (index) {
0 -> {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Welcome to",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.displayLargeEmphasized,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.app_description),
style = MaterialTheme.typography.bodyMediumEmphasized,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(64.dp))
FilledTonalIconButton(
onClick = {
animationScope.launch {
state.animateScrollToItem(1)
}
},
modifier = Modifier
.minimumInteractiveComponentSize()
.size(
IconButtonDefaults.largeContainerSize(
IconButtonDefaults.IconButtonWidthOption.Wide
)
),
shape = IconButtonDefaults.largeRoundShape
) {
Icon(
Icons.AutoMirrored.Default.ArrowForward,
contentDescription = "forward",
modifier = Modifier.size(IconButtonDefaults.largeIconSize),
)
}
}
}
}
1 -> {
PrivacyPolicyPage(
onForward = {
animationScope.launch {
if (isSupported) state.animateScrollToItem(3) else state.animateScrollToItem(2)
}
}
)
}
2 -> {
NotSupportedPage(
bypassCompatibilityCheck = {
bypassDeviceCheck(sharedPreferences)
}
)
}
3 -> {
PermissionsPage(
onBackward = {
animationScope.launch {
if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2)
}
},
onForward = onOnboardingComplete
)
}
}
}
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Preview
@Composable
fun OnboardingScreenPreview(){
OnboardingScreen {}
}
@@ -0,0 +1,345 @@
package me.kavishdevar.librepods.presentation.screens.onboarding
import android.content.Intent
import android.provider.Settings
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import me.kavishdevar.librepods.presentation.MaterialIcons
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsPage(
onBackward: () -> Unit,
onForward: () -> Unit
) {
var grantingAll = false
val context = LocalContext.current
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val phonePermissionState = rememberMultiplePermissionsState(
listOf(
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
) {
if (grantingAll) {
if (!canDrawOverlays.value) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${context.packageName}".toUri()
)
context.startActivity(intent)
}
}
}
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
if (grantingAll) {
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
val bluetoothPermissionsState = rememberMultiplePermissionsState(
listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE"
)
) {
if (grantingAll) {
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val scrollState = rememberScrollState()
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
StyledList(title = "Required Permissions") {
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedBluetoothContainerColor by animateColorAsState(
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
StyledListItem(
name = "Bluetooth",
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
{
grantingAll = false
bluetoothPermissionsState.launchMultiplePermissionRequest()
}
} else null,
description = "Required to communicate with AirPods",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedBluetoothContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.bluetooth,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedBluetoothIconColor
)
}
},
)
}
StyledList(title = "Optional Permissions") {
val animatedNotificationsIconColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
val animatedNotificationsContainerColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedPhoneContainerColor by animateColorAsState(
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
)
StyledListItem(
name = "Notifications",
onClick = if (!notificationPermissionState.status.isGranted) {
{
grantingAll = false
notificationPermissionState.launchPermissionRequest()
}
} else null,
description = "Show battery status",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedNotificationsContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.notifications,
contentDescription = "notifications",
modifier = Modifier.size(24.dp),
tint = animatedNotificationsIconColor
)
}
},
)
StyledListItem(
name = "Phone",
onClick = if (!phonePermissionState.allPermissionsGranted) {
{
grantingAll = false
phonePermissionState.launchMultiplePermissionRequest()
}
} else null,
description = "Respond to phone calls with head gestures",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedPhoneContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.call,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedPhoneIconColor
)
}
},
)
}
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest)
StyledListItem(
name = "Display over other apps",
onClick = if (!canDrawOverlays.value) {
{
grantingAll = false
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
"package:${context.packageName}".toUri()
)
context.startActivity(intent)
}
} else null,
description = "Show popups when AirPods are nearby or audio switches to them.",
orientation = ListItemOrientation.Vertical,
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedOverlayContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.stack,
contentDescription = "bluetooth",
modifier = Modifier.size(24.dp),
tint = animatedOverlayIconColor
)
}
},
)
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledIconButton(
onClick = onBackward,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "backward",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
Button(
onClick = {
grantingAll = true
if (!bluetoothPermissionsState.allPermissionsGranted) bluetoothPermissionsState.launchMultiplePermissionRequest()
else if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value =
Settings.canDrawOverlays(context)
},
modifier = Modifier
.height(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow).height)
.weight(1f),
enabled = !bluetoothPermissionsState.allPermissionsGranted || !notificationPermissionState.status.isGranted || !phonePermissionState.allPermissionsGranted || !canDrawOverlays.value
) {
Text(
text = "Grant all",
style = MaterialTheme.typography.labelMedium
)
}
FilledIconButton(
onClick = onForward,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(
IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)
),
shape = IconButtonDefaults.mediumRoundShape,
enabled = bluetoothPermissionsState.allPermissionsGranted
) {
Icon(
Icons.AutoMirrored.Default.ArrowForward,
contentDescription = "forward",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
}
}
}
}
@@ -0,0 +1,197 @@
package me.kavishdevar.librepods.presentation.screens.onboarding
import androidx.compose.foundation.background
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.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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@Composable
fun PrivacyPolicyPage(
onForward: () -> Unit
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(42.dp)
)
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Last updated: 20 June 2026",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Third Party Services",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Email",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "You can edit or remove any of this information before sending the email.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Discord",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "GitHub Issues",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
style = MaterialTheme.typography.bodyMedium
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(start = 8.dp)
) {
Text(
"• LibrePods version name and version code",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Device manufacturer and model",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Android build information",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "• Installation source (Google Play or GitHub)",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Payments", style = MaterialTheme.typography.titleLarge
)
if (BuildConfig.PLAY_BUILD) {
Text(
text = "Google Play", style = MaterialTheme.typography.titleMedium
)
Text(
text = "When using the version available on Google Play, purchases are processed by Google Play.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium
)
Text(
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Contact", style = MaterialTheme.typography.titleLarge
)
Text(
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
style = MaterialTheme.typography.bodyMedium
)
Button(
onClick = onForward,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.i_agree),
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,12 @@
package me.kavishdevar.librepods.presentation.theme
import androidx.compose.runtime.compositionLocalOf
enum class DesignSystem {
Apple,
Material
}
val LocalDesignSystem = compositionLocalOf {
DesignSystem.Apple
}
@@ -18,47 +18,71 @@
package me.kavishdevar.librepods.presentation.theme package me.kavishdevar.librepods.presentation.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme( val ColorScheme.sectionHeader: Color
primary = Purple80, get() = onBackground.copy(alpha = 0.6f)
secondary = PurpleGrey80,
tertiary = Pink80 private val AppleDarkColorScheme = darkColorScheme(
surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that
onBackground = Color(0xFFFFFFFF),
surface = Color(0xFF1C1C1E),
onSurface = Color(0xFFFFFFFF),
surfaceDim = Color(0x40888888),
primary = Color(0xFF0091FF),
secondaryContainer = Color(0xFF366AA8),
onSecondaryContainer = Color(0xFF0091FF),
onPrimary = Color(0xFFFFFFFF)
) )
private val LightColorScheme = lightColorScheme( private val AppleLightColorScheme = lightColorScheme(
primary = Purple40, surfaceContainer = Color(0xFFF2F2F7),
secondary = PurpleGrey40, onBackground = Color(0xFF000000),
tertiary = Pink40 surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF000000),
surfaceDim = Color(0x40D9D9D9),
secondaryContainer = Color(0xFF6BC0FF),
onSecondaryContainer = Color(0xFF0088FF),
primary = Color(0xFF0088FF),
onPrimary = Color(0xFFFFFFFF)
) )
@Composable @Composable
fun LibrePodsTheme( fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, m3eEnabled: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { m3eEnabled -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> AppleDarkColorScheme
darkTheme -> DarkColorScheme else -> AppleLightColorScheme
else -> LightColorScheme
} }
MaterialTheme( CompositionLocalProvider(
LocalDesignSystem provides
if (m3eEnabled) DesignSystem.Material
else DesignSystem.Apple
) {
MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, motionScheme = MotionScheme.expressive(),
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
content = content content = content
) )
}
} }
@@ -18,35 +18,400 @@
package me.kavishdevar.librepods.presentation.theme package me.kavishdevar.librepods.presentation.theme
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
// Set of Material typography styles to start with val sfProFamily = FontFamily(Font(R.font.sf_pro))
val Typography = Typography(
bodyLarge = TextStyle( val AppleTypography = Typography().run {
fontFamily = FontFamily.Default, copy(
fontWeight = FontWeight.Normal, displayLarge = displayLarge.copy(fontFamily = sfProFamily),
displayMedium = displayMedium.copy(fontFamily = sfProFamily),
displaySmall = displaySmall.copy(fontFamily = sfProFamily),
headlineLarge = headlineLarge.copy(fontFamily = sfProFamily),
headlineMedium = headlineMedium.copy(fontFamily = sfProFamily),
headlineSmall = headlineSmall.copy(fontFamily = sfProFamily),
titleLarge = titleLarge.copy(fontFamily = sfProFamily),
titleMedium = titleMedium.copy(fontFamily = sfProFamily),
titleSmall = titleSmall.copy(fontFamily = sfProFamily),
bodyLarge = bodyLarge.copy(fontFamily = sfProFamily),
bodyMedium = bodyMedium.copy(
fontFamily = sfProFamily,
fontSize = 16.sp
),
bodySmall = bodySmall.copy(
fontFamily = sfProFamily,
fontSize = 14.sp,
lineHeight = 18.sp
),
labelLarge = labelLarge.copy(fontFamily = sfProFamily),
labelMedium = labelMedium.copy(
fontFamily = sfProFamily,
fontSize = 16.sp,
),
labelMediumEmphasized = labelMediumEmphasized.copy(
fontFamily = sfProFamily,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
),
labelSmallEmphasized = labelSmallEmphasized.copy(
fontFamily = sfProFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
)
}
val provider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs
)
private fun robotoFlex(
wght: Float = 400f,
slnt: Float = 0f,
grad: Float = 0f,
wdth: Float = 100f,
xtra: Float = 468f,
xopq: Float = 96f,
yopq: Float = 79f,
) = FontFamily(
androidx.compose.ui.text.googlefonts.Font(
// Font(
// resId = R.font.roboto_flex,
googleFont = GoogleFont("Roboto Flex"),
fontProvider = provider,
variationSettings = FontVariation.Settings(
FontVariation.Setting("wght", wght),
FontVariation.Setting("wdth", wdth),
FontVariation.Setting("slnt", slnt),
FontVariation.Setting("grad", grad),
FontVariation.Setting("xtra", xtra),
FontVariation.Setting("xopq", xopq),
FontVariation.Setting("yopq", yopq),
)
)
)
val display = robotoFlex(
wght = 800f,
grad = 100f,
wdth = 100f
)
val displayEmphasized = robotoFlex(
wght = 1000f,
slnt = -2f,
grad = 150f,
wdth = 150f,
)
val body = robotoFlex()
val bodyEmphasized = robotoFlex(
wght = 600f,
wdth = 130f,
grad = 75f,
)
val label = robotoFlex(
wght = 450f,
grad = 50f
)
val labelEmphasized = robotoFlex(
wght = 600f,
wdth = 140f,
grad = 75f
)
val MaterialTypography = Typography().run {
copy(
titleSmall = titleSmall.copy(
fontFamily = display,
fontSize = 24.sp,
lineHeight = 30.sp,
),
titleMedium = titleMedium.copy(
fontFamily = display,
fontSize = 28.sp,
lineHeight = 32.sp,
),
titleLarge = titleLarge.copy(
fontFamily = display,
fontSize = 32.sp,
lineHeight = 36.sp,
),
titleSmallEmphasized = titleSmallEmphasized.copy(
fontFamily = display,
fontSize = 24.sp,
lineHeight = 30.sp,
),
titleMediumEmphasized = titleMediumEmphasized.copy(
fontFamily = displayEmphasized,
fontSize = 28.sp,
lineHeight = 32.sp,
),
titleLargeEmphasized = titleLargeEmphasized.copy(
fontFamily = displayEmphasized,
fontSize = 32.sp,
lineHeight = 36.sp,
),
displaySmall = displaySmall.copy(
fontFamily = display,
fontSize = 32.sp,
lineHeight = 36.sp,
),
displayMedium = displayMedium.copy(
fontFamily = display,
fontSize = 36.sp,
lineHeight = 40.sp,
),
displayLarge = displayLarge.copy(
fontFamily = display,
fontSize = 40.sp,
lineHeight = 44.sp,
),
displaySmallEmphasized = displaySmallEmphasized.copy(
fontFamily = displayEmphasized,
fontSize = 38.sp,
lineHeight = 42.sp,
),
displayMediumEmphasized = displayMediumEmphasized.copy(
fontFamily = displayEmphasized,
fontSize = 42.sp,
lineHeight = 48.sp,
),
displayLargeEmphasized = displayLargeEmphasized.copy(
fontFamily = displayEmphasized,
fontSize = 48.sp,
lineHeight = 52.sp,
),
bodySmall = bodySmall.copy(
fontFamily = body,
fontSize = 14.sp,
lineHeight = 20.sp,
),
bodyMedium = bodyMedium.copy(
fontFamily = body,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.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, bodyLarge = bodyLarge.copy(
fontWeight = FontWeight.Medium, fontFamily = body,
fontSize = 11.sp, fontSize = 18.sp,
lineHeight = 16.sp, lineHeight = 28.sp,
letterSpacing = 0.5.sp ),
bodySmallEmphasized = bodySmallEmphasized.copy(
fontFamily = bodyEmphasized,
fontSize = 14.sp,
lineHeight = 20.sp,
),
bodyMediumEmphasized = bodyMediumEmphasized.copy(
fontFamily = bodyEmphasized,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyLargeEmphasized = bodyLargeEmphasized.copy(
fontFamily = bodyEmphasized,
fontSize = 18.sp,
lineHeight = 28.sp,
),
labelSmall = labelSmall.copy(
fontFamily = label,
fontSize = 14.sp,
lineHeight = 18.sp,
),
labelMedium = labelMedium.copy(
fontFamily = label,
fontSize = 16.sp,
lineHeight = 20.sp,
),
labelLarge = labelLarge.copy(
fontFamily = label,
fontSize = 18.sp,
lineHeight = 22.sp,
),
labelSmallEmphasized = labelSmallEmphasized.copy(
fontFamily = labelEmphasized,
fontSize = 14.sp,
lineHeight = 18.sp,
),
labelMediumEmphasized = labelMediumEmphasized.copy(
fontFamily = labelEmphasized,
fontSize = 16.sp,
lineHeight = 20.sp,
),
labelLargeEmphasized = labelLargeEmphasized.copy(
fontFamily = labelEmphasized,
fontSize = 18.sp,
lineHeight = 22.sp,
),
) )
*/ }
@Preview(
name = "Typography Showcase",
showBackground = true,
device = PIXEL_9_PRO_XL
) )
@Composable
private fun TypographyPreview() {
LibrePodsTheme (m3eEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(28.dp))
.padding(24.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Display Large",
style = MaterialTheme.typography.displayLarge
)
Text(
"Display Large Emphasized",
style = MaterialTheme.typography.displayLargeEmphasized
)
Text(
"Display Medium",
style = MaterialTheme.typography.displayMedium
)
Text(
"Display Medium Emphasized",
style = MaterialTheme.typography.displayMediumEmphasized
)
Text(
"Display Small",
style = MaterialTheme.typography.displaySmall
)
Text(
"Display Small Emphasized",
style = MaterialTheme.typography.displaySmallEmphasized
)
HorizontalDivider()
Text(
"Body Large",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Body Large Emphasized",
style = MaterialTheme.typography.bodyLargeEmphasized
)
Text(
"Body Medium",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Body Medium Emphasized",
style = MaterialTheme.typography.bodyMediumEmphasized
)
Text(
"Body Small",
style = MaterialTheme.typography.bodySmall
)
Text(
"Body Small Emphasized",
style = MaterialTheme.typography.bodySmallEmphasized
)
HorizontalDivider()
Text(
"Label Large",
style = MaterialTheme.typography.labelLarge
)
Text(
"Label Large Emphasized",
style = MaterialTheme.typography.labelLargeEmphasized
)
Text(
"Label Medium",
style = MaterialTheme.typography.labelMedium
)
Text(
"Label Medium Emphasized",
style = MaterialTheme.typography.labelMediumEmphasized
)
Text(
"Label Small",
style = MaterialTheme.typography.labelSmall
)
Text(
"Label Small Emphasized",
style = MaterialTheme.typography.labelSmallEmphasized
)
}
}
}
}
@@ -23,20 +23,26 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -45,13 +51,14 @@ import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.CustomEq
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
@Suppress("ArrayInDataClass") @Suppress("ArrayInDataClass")
data class AirPodsUiState( data class AirPodsUiState(
val deviceName: String, val deviceName: String = "AirPods",
val isLocallyConnected: Boolean = false, val isLocallyConnected: Boolean = false,
@@ -87,27 +94,137 @@ data class AirPodsUiState(
val hearingAidData: ByteArray = byteArrayOf(), val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false, val isPremium: Boolean = false,
val vendorIdHook: Boolean = false val vendorIdHook: Boolean = false,
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
)
val demoInstance = AirPodsInstance(
name = "AirPods Pro",
model = AirPodsModels.getModelByModelNumber("A3064")!!,
actualModelNumber = "A3064",
serialNumber = "JXF9Q94A40",
leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO",
version1 = "90.3388000000000000.1786",
version2 = "90.3388000000000000.1786",
version3 = "9441861",
)
val demoState = AirPodsUiState(
deviceName = demoInstance.name,
isLocallyConnected = true,
capabilities = demoInstance.model.capabilities,
battery = listOf(
Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING)
),
ancMode = 3,
offListeningMode = false,
modelName = demoInstance.model.displayName,
actualModel = demoInstance.actualModelNumber,
serialNumbers = listOf(
demoInstance.serialNumber?: "",
demoInstance.leftSerialNumber?: "",
demoInstance.rightSerialNumber?: ""
),
version1 = demoInstance.version1?: "",
version2 = demoInstance.version2?: "",
version3 = demoInstance.version3?: "",
headTrackingActive = true,
headGesturesEnabled = true,
automaticEarDetectionEnabled = true,
automaticConnectionEnabled = true,
leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
rightAction = StemAction.DIGITAL_ASSISTANT,
loudSoundReductionEnabled = true,
isPremium = true,
vendorIdHook = true,
dynamicEndOfCharge = true,
connectionSuccessful = true,
customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70),
controlStates = mapOf(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00),
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00),
ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01),
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03),
ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50),
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02),
ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01),
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02),
ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01),
ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45),
ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01),
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01),
ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52),
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01),
ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04)
)
) )
class AirPodsViewModel( class AirPodsViewModel(
private val service: AirPodsService,
private val sharedPreferences: SharedPreferences,
private val controlRepo: ControlCommandRepository,
private val appContext: Context
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow( private lateinit var sharedPreferences: SharedPreferences
AirPodsUiState( private lateinit var appContext: Context
deviceName = sharedPreferences.getString( private lateinit var service: AirPodsService
"name", private lateinit var controlRepo: ControlCommandRepository
"AirPods Pro"
) ?: "AirPods Pro" var isReady by mutableStateOf(false)
) private set
)
fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) {
this.service = service
this.controlRepo = controlRepo
this.sharedPreferences = sharedPreferences
this.appContext = appContext
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
observeAACP()
loadCurrentStatus()
loadEq()
loadATT()
observeATT()
observeSharedPreferences()
observeBilling()
if (isDemoMode) activateDemoMode()
isReady = true
}
private val _uiState = MutableStateFlow(AirPodsUiState())
val uiState: StateFlow<AirPodsUiState> = _uiState val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>()
private val listeners = private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>() mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
@@ -116,40 +233,48 @@ class AirPodsViewModel(
private lateinit var broadcastReceiver: BroadcastReceiver private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow( // private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null) // sharedPreferences.getString("camera_action", null)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) // ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
//
// val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
//
// fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
// sharedPreferences.edit {
// if (action == null) remove("camera_action")
// else putString("camera_action", action.name)
// }
// _cameraAction.value = action
// }
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction fun setCustomEq(low: Int, mid: Int, high: Int) {
require(low in 0..100)
fun setCameraAction(action: AACPManager.Companion.StemPressType?) { require(mid in 0..100)
sharedPreferences.edit { require(high in 0..100)
if (action == null) remove("camera_action") val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
else putString("camera_action", action.name) service.aacpManager.sendCustomEqPacket(updatedEq)
_uiState.update {
it.copy(
customEq = updatedEq
)
} }
_cameraAction.value = action
} }
init { fun setCustomEqEnabled(enabled: Boolean) {
observeBroadcasts() service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
loadName() _uiState.update {
loadInstance() it.copy(
loadSharedPreferences() customEq = it.customEq.copy(state = if (enabled) 2 else 1)
setupControlObservers() )
observeBilling() }
loadControlList()
observeATT()
if (isDemoMode) activateDemoMode()
} }
override fun onCleared() { override fun onCleared() {
listeners.forEach { (id, listener) -> listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener) controlRepo.remove(id, listener)
} }
service.aacpManager.customEqCallback = null
appContext.unregisterReceiver(broadcastReceiver) appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
} }
private fun loadName() { private fun loadName() {
@@ -160,23 +285,38 @@ class AirPodsViewModel(
private fun observeBilling() { private fun observeBilling() {
if (isDemoMode) return if (isDemoMode) return
viewModelScope.launch { viewModelScope.launch {
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium -> BillingManager.provider.isPremium.collect { premium ->
// if (!billingFirstCollectDone) { if (premium) {
// billingFirstCollectDone = true sharedPreferences.edit {
// return@collect remove("premium_expiry_time")
// } if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
if (!premium) { }
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
setControlCommandBoolean( setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false false
) )
setHeadGesturesEnabled(false) setHeadGesturesEnabled(false)
} _uiState.update { it.copy(isPremium = false) }
_uiState.update { it.copy(isPremium = premium) }
} }
} }
} }
}
}
private fun observeSharedPreferences() {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"name" -> loadName()
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
"head_gestures", "left_long_press_action", "right_long_press_action",
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun observeBroadcasts() { private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() {
@@ -266,17 +406,24 @@ class AirPodsViewModel(
val current = state.controlStates[identifier] val current = state.controlStates[identifier]
if (current?.contentEquals(value) == true) return@update state if (current?.contentEquals(value) == true) return@update state
if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) {
state.copy(
dynamicEndOfCharge = value[0] == 0x01.toByte(),
controlStates = state.controlStates + (identifier to value)
)
} else {
state.copy( state.copy(
controlStates = state.controlStates + (identifier to value) controlStates = state.controlStates + (identifier to value)
) )
} }
} }
}
listeners[identifier] = listener listeners[identifier] = listener
} }
// I'm lazy, sorry. // I'm lazy, sorry.
fun setupControlObservers() { fun observeAACP() {
val identifiersList = listOf( val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE, ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
@@ -303,18 +450,25 @@ class AirPodsViewModel(
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
ControlCommandIdentifiers.OWNS_CONNECTION, ControlCommandIdentifiers.OWNS_CONNECTION,
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE
) )
for (identifier in identifiersList) { for (identifier in identifiersList) {
observeControl(identifier) observeControl(identifier)
} }
service.aacpManager.customEqCallback = { customEq ->
_uiState.update { it.copy(customEq = customEq) }
}
} }
fun refreshInitialData() { fun loadCurrentStatus() {
if (isDemoMode) return if (isDemoMode) return
service.let { service -> service.let { service ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLocallyConnected = service.isConnected(), battery = service.getBattery() isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
battery = service.getBattery(),
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
controlStates = controlRepo.getMap()
) )
} }
} }
@@ -340,6 +494,9 @@ class AirPodsViewModel(
) ?: "CYCLE_NOISE_CONTROL_MODES" ) ?: "CYCLE_NOISE_CONTROL_MODES"
) )
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false)
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -349,9 +506,60 @@ class AirPodsViewModel(
headGesturesEnabled = headGesturesEnabled, headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction, leftAction = leftAction,
rightAction = rightAction, rightAction = rightAction,
vendorIdHook = vendorIdHook vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
connectionSuccessful = connectionSuccessful,
) )
} }
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
if (BuildConfig.PLAY_BUILD) {
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
}
} }
fun setOffListeningMode(enabled: Boolean) { fun setOffListeningMode(enabled: Boolean) {
@@ -369,10 +577,18 @@ class AirPodsViewModel(
} }
} }
private fun loadControlList() { fun setDynamicEndOfCharge(enabled: Boolean) {
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
_uiState.update {
it.copy(dynamicEndOfCharge = enabled)
}
}
private fun loadEq() {
_uiState.update { _uiState.update {
it.copy( it.copy(
controlStates = controlRepo.getMap() customEq = service.aacpManager.customEq
) )
} }
} }
@@ -427,51 +643,68 @@ class AirPodsViewModel(
} }
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) { when (handle) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) } // ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
ATTHandles.LOUD_SOUND_REDUCTION -> {
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
}
ATTHandles.HEARING_AID -> {
_uiState.value = _uiState.value.copy(hearingAidData = value)
}
ATTHandles.TRANSPARENCY -> {
_uiState.value = _uiState.value.copy(transparencyData = value)
}
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
service.attManager?.connect() service.attManager.writeCharacteristic(handle, value)
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
} }
} }
fun refreshATT() { fun loadATT() {
viewModelScope.launch(Dispatchers.IO) { val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReduction = val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull() loudSoundReduction[0].toInt() == 1
val transparencyData = } else false
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf() val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
val hearingAid = val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf() _uiState.update {
_uiState.value = _uiState.value.copy( it.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01, loudSoundReductionEnabled = loudSoundReductionEnabled,
transparencyData = transparencyData, transparencyData = transparencyData,
hearingAidData = hearingAid hearingAidData = hearingAidData
) )
} }
} }
fun observeATT() { fun observeATT() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect() service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
while (service.attManager?.socket?.isConnected != true) { service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
delay(1000) }
service.attManager.setOnNotificationReceived { handle, value ->
when (handle) {
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
value[0].toInt() == 1
} else false
_uiState.update {
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
}
}
ATTHandles.HEARING_AID.value.toByte() -> {
_uiState.update {
it.copy(hearingAidData = value)
}
}
ATTHandles.TRANSPARENCY.value.toByte() -> {
_uiState.update {
it.copy(transparencyData = value)
}
} }
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(15000)
} }
} }
} }
@@ -498,47 +731,56 @@ class AirPodsViewModel(
fun activateDemoMode() { fun activateDemoMode() {
isDemoMode = true isDemoMode = true
viewModelScope.launch { _uiState.update {demoState}
demoActivated.emit(Unit)
}
val fakeInstance = AirPodsInstance(
name = "AirPods Pro (Demo)",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
serialNumber = "DEMO123",
leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO",
version1 = "1.0",
version2 = "1.0",
version3 = "1.0",
)
_uiState.update {
it.copy(
isLocallyConnected = true,
instance = fakeInstance,
capabilities = fakeInstance.model.capabilities,
battery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 25, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.CASE, 85, BatteryStatus.CHARGING),
),
modelName = fakeInstance.model.displayName,
actualModel = fakeInstance.actualModelNumber,
serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
version3 = "Demo Firmware",
// isPremium = true
)
}
} }
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte) service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} }
fun setLongPressAction(side: String, action: StemAction) {
val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
sharedPreferences.edit { putString(prefKey, action.name) }
_uiState.update {
if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action)
}
}
private fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
return count
}
fun toggleListeningMode(modeBit: Int) {
val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
val newValue = if ((currentByte and modeBit) != 0) {
val temp = currentByte and modeBit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or modeBit
}
setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte())
sharedPreferences.edit { putInt("long_press_byte", newValue) }
}
fun disconnect() { fun disconnect() {
if (isDemoMode) {
isDemoMode = false
_uiState.update {
it.copy(isLocallyConnected = false)
}
} else {
service.disconnectAirPods() service.disconnectAirPods()
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(
appContext, "App has disconnected, disconnect from Android Settings.",
Toast.LENGTH_LONG
).show()
}
}
} }
} }
@@ -13,7 +13,6 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class AppSettingsUiState( data class AppSettingsUiState(
@@ -34,7 +33,11 @@ data class AppSettingsUiState(
val cameraPackageError: String? = null, val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false, val vendorIdHook: Boolean = false,
val isPremium: Boolean = false, val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L,
val m3eEnabled: Boolean = false
) )
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -60,18 +63,76 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
override fun onCleared() { override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
} }
private fun observeBilling() { private fun observeBilling() {
viewModelScope.launch { viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium -> BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) } if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
// No billing premium, only update if no temporary premium is active
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
_uiState.update { it.copy(isPremium = false) }
}
}
} }
} }
} }
private fun loadSettings() { private fun loadSettings() {
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
_uiState.update { currentState -> _uiState.update { currentState ->
currentState.copy( currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false), showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
@@ -88,12 +149,12 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false), vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true),
m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true)
) )
} }
if (BuildConfig.FLAVOR == "xposed") {
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
}
} }
fun setShowPhoneBatteryInWidget(enabled: Boolean) { fun setShowPhoneBatteryInWidget(enabled: Boolean) {
@@ -178,8 +239,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
} }
fun setVendorIdHook(enabled: Boolean) { fun setVendorIdHook(enabled: Boolean) {
NativeBridge.setSdpHook(enabled)
xposedRemotePref.putBoolean("vendor_id_hook", enabled) xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) } _uiState.update { it.copy(vendorIdHook = enabled) }
} }
fun setShowBottomSheetPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
_uiState.update { it.copy(showBottomSheetPopup = enabled) }
}
fun setShowIslandPopup(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
_uiState.update { it.copy(showIslandPopup = enabled) }
}
fun setm3eEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("m3e_enabled", enabled) }
_uiState.update { it.copy(m3eEnabled = enabled) }
}
} }
@@ -28,8 +28,8 @@ import android.content.Intent
import android.util.Log import android.util.Log
import android.widget.RemoteViews import android.widget.RemoteViews
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() { class NoiseControlWidget : AppWidgetProvider() {
@@ -82,8 +82,14 @@ class NoiseControlWidget : AppWidgetProvider() {
if (intent.action == "ACTION_SET_ANC_MODE") { if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1) val mode = intent.getIntExtra("ANC_MODE", 1)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode") Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
ServiceManager.getService()!! val service = ServiceManager.getService()
.aacpManager
if (service == null) {
Log.w("NoiseControlWidget", "Service unavailable")
return
}
service.aacpManager
.sendControlCommand( .sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
mode.toByte() mode.toByte()

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