mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-10 22:48:30 +02:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b06d780eee | |||
| 70f420dedb | |||
| 23193ceb39 | |||
| cb246d1287 | |||
| 95cd677da9 | |||
| 0d049d93fb | |||
| 469d948061 | |||
| f5d92768e2 | |||
| 8cb2951bc6 | |||
| bb578dab23 | |||
| b1b47048a3 | |||
| bf09300dfe | |||
| 70165232c0 | |||
| aabbc902cb | |||
| 0ee7056600 | |||
| 8b24ac49e2 | |||
| d2dd722bc7 | |||
| 67fc93bde5 | |||
| 0b578d62cf | |||
| 072b9b4dac | |||
| 0af60cd8a9 | |||
| be29a46dab | |||
| 7461f7dfb7 | |||
| 904c00afce | |||
| 6272357d84 | |||
| 6ac6700be6 | |||
| 113ee0a966 | |||
| d82e4e2427 | |||
| 481d5f13cf | |||
| ef221af505 | |||
| c19190f031 | |||
| d0b8574c68 | |||
| 294d733e71 | |||
| f6d7e97796 | |||
| ae174bc9ea | |||
| 1804e80cba | |||
| 0b8bd5a5b8 | |||
| d1d48562d7 | |||
| c84e64e656 | |||
| 51739514fa | |||
| 8f9a04390a | |||
| 2363b80548 |
@@ -1,4 +1,4 @@
|
|||||||
name: Build APK and root module (and create nightly release)
|
name: Android CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,95 +6,138 @@ on:
|
|||||||
- '*'
|
- '*'
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '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 }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: zulu
|
||||||
java-version: 21
|
java-version: 21
|
||||||
- uses: gradle/actions/setup-gradle@v4
|
- uses: gradle/actions/setup-gradle@v4
|
||||||
- name: Build debug APK
|
- name: Decode keystore
|
||||||
run: ./gradlew assembleDebug
|
run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
- name: Accept Licenses
|
||||||
|
run: yes | sdkmanager --licenses
|
||||||
|
- name: Install NDK
|
||||||
|
run: sdkmanager "ndk;30.0.14904198"
|
||||||
|
- name: Create local.properties
|
||||||
|
run: |
|
||||||
|
cat <<EOF > android/local.properties
|
||||||
|
RELEASE_STORE_FILE=../release.keystore
|
||||||
|
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||||
|
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
|
||||||
|
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
- name: Build
|
||||||
|
run: ./gradlew packageReleaseArtifacts
|
||||||
working-directory: android
|
working-directory: android
|
||||||
- name: Upload artifact
|
- id: vars
|
||||||
uses: actions/upload-artifact@v4
|
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Debug APK
|
name: apk-release
|
||||||
path: android/app/build/outputs/apk/**/*.apk
|
path: release/*release.apk
|
||||||
nightly-release:
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: apk-debug
|
||||||
|
path: release/*debug.apk
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: root-module-release
|
||||||
|
path: release/*release.zip
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: root-module-debug
|
||||||
|
path: release/*debug.zip
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-bundle
|
||||||
|
path: release/*.aab
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true'
|
needs: build
|
||||||
needs: build-debug-apk
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
- name: Export APK_NAME for later use
|
with:
|
||||||
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
|
name: apk-release
|
||||||
- name: Rename .apk file
|
path: artifacts/apk-release
|
||||||
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
|
|
||||||
- name: Decode keystore file
|
|
||||||
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
|
|
||||||
- name: Install apksigner
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y apksigner
|
|
||||||
- name: Sign APK
|
|
||||||
run: |
|
|
||||||
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
|
|
||||||
- name: Verify APK
|
|
||||||
run: apksigner verify "./$APK_NAME"
|
|
||||||
- name: Fetch the latest non-nightly release tag
|
|
||||||
id: fetch-tag
|
|
||||||
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
|
|
||||||
- name: Retrieve commits since the last release
|
|
||||||
id: get-commits
|
|
||||||
run: |
|
|
||||||
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
|
|
||||||
echo "::set-output name=commits::${COMMITS}"
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release-notes
|
|
||||||
run: |
|
|
||||||
# Create a temporary file for release notes
|
|
||||||
NOTES_FILE=$(mktemp)
|
|
||||||
|
|
||||||
# Process custom notes if they exist
|
- uses: actions/download-artifact@v4
|
||||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
|
with:
|
||||||
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
|
name: apk-debug
|
||||||
|
path: artifacts/apk-debug
|
||||||
|
|
||||||
# Check if custom notes already have bullet points or GitHub-style formatting
|
- uses: actions/download-artifact@v4
|
||||||
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
|
with:
|
||||||
# Already formatted, use as is
|
name: root-module-release
|
||||||
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
|
path: artifacts/root-module-release
|
||||||
else
|
|
||||||
# Add bullet point formatting
|
|
||||||
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
|
- uses: actions/download-artifact@v4
|
||||||
- name: Zip root-module directory
|
with:
|
||||||
run: sh ./build-magisk-module.sh
|
name: root-module-debug
|
||||||
- name: Delete release if exist then create release
|
path: artifacts/root-module-debug
|
||||||
|
- id: prev
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
|
TAG=$(gh release list \
|
||||||
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes
|
--limit 1 \
|
||||||
|
--json tagName \
|
||||||
|
--jq '.[0].tagName')
|
||||||
|
|
||||||
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
- id: changelog
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
|
||||||
|
NOTES=$(git log --pretty=format:"- %s (%h)")
|
||||||
|
else
|
||||||
|
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s (%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
|
||||||
|
|||||||
+1
-6
@@ -1,10 +1,5 @@
|
|||||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
release
|
||||||
wak.toml
|
|
||||||
log.txt
|
|
||||||
btl2capfix.zip
|
|
||||||
root-module-manual
|
|
||||||
.vscode
|
.vscode
|
||||||
testing.py
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
CMakeLists.txt.user*
|
CMakeLists.txt.user*
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,23 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
|||||||
|
|
||||||
### Root Requirement
|
### Root Requirement
|
||||||
|
|
||||||
If you are using ColorOS/OxygenOS 16, Android 16 QPR3, Android 17 Beta 3 or higher, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root.
|
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.
|
||||||
|
|
||||||
For everyone else:
|
[https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
|
||||||
**You must have a rooted device with Xposed to use LibrePods on Android.**
|
|
||||||
|
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
|
### A few notes
|
||||||
|
|
||||||
@@ -129,7 +142,7 @@ A huge thank you to everyone supporting the project!
|
|||||||
- 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
|
# Nightly/Development Builds
|
||||||
|
|
||||||
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
|
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
|
||||||
|
|
||||||
@@ -140,7 +153,7 @@ Want to try the latest features before they're officially released? You can grab
|
|||||||
4. Extract the zip and install the `.apk` on your device
|
4. Extract the zip and install the `.apk` on your device
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update — you may need to uninstall the stable version first.
|
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
|
||||||
|
|
||||||
### Linux (Rust)
|
### Linux (Rust)
|
||||||
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
|
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
|
||||||
|
|||||||
+158
-23
@@ -1,42 +1,77 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
val appVersionName = "0.2.6"
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.aboutLibraries)
|
alias(libs.plugins.aboutLibraries)
|
||||||
|
// alias(libs.plugins.hilt)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val props = Properties().apply {
|
||||||
|
load(rootProject.file("local.properties").inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(props["RELEASE_STORE_FILE"] as String)
|
||||||
|
storePassword = props["RELEASE_STORE_PASSWORD"] as String
|
||||||
|
keyAlias = props["RELEASE_KEY_ALIAS"] as String
|
||||||
|
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
namespace = "me.kavishdevar.librepods"
|
namespace = "me.kavishdevar.librepods"
|
||||||
compileSdk = 36
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
minSdk = 33
|
minSdk = 33
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = 9
|
versionCode = 43
|
||||||
versionName = "0.2.0"
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
)
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
create("playRelease") {
|
||||||
|
initWith(getByName("release"))
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||||
|
versionNameSuffix = "-play"
|
||||||
|
}
|
||||||
|
create("playDebug") {
|
||||||
|
initWith(getByName("debug"))
|
||||||
|
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||||
|
versionNameSuffix = "-youshouldnothavethis"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
@@ -49,18 +84,41 @@ android {
|
|||||||
}
|
}
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
res.srcDirs("src/main/res", "src/main/res-apple")
|
res.directories += "src/main/res-apple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ndkVersion = "30.0.14904198"
|
||||||
|
|
||||||
|
flavorDimensions += "env"
|
||||||
|
|
||||||
|
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(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.hiddenapibypass)
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.ui.graphics)
|
implementation(libs.androidx.ui.graphics)
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
@@ -72,21 +130,98 @@ dependencies {
|
|||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
implementation(libs.androidx.dynamicanimation)
|
implementation(libs.androidx.dynamicanimation)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
|
implementation(libs.billing)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
implementation(libs.androidx.compose.foundation.layout)
|
implementation(libs.androidx.compose.foundation.layout)
|
||||||
implementation(libs.aboutlibraries)
|
implementation(libs.aboutlibraries)
|
||||||
implementation(libs.aboutlibraries.compose.m3)
|
implementation(libs.aboutlibraries.compose.m3)
|
||||||
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
implementation(libs.backdrop)
|
||||||
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
|
// implementation(libs.hilt)
|
||||||
compileOnly(files("libs/libxposed-api-100.aar"))
|
// implementation(libs.hilt.compiler)
|
||||||
debugImplementation(files("libs/backdrop-debug.aar"))
|
add("xposedCompileOnly", libs.libxposed.api)
|
||||||
releaseImplementation(files("libs/backdrop-release.aar"))
|
add("xposedImplementation", libs.libxposed.service)
|
||||||
|
add("playReleaseImplementation", libs.billing)
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
export{
|
export {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
excludeFields = listOf("generated")
|
excludeFields = listOf("generated")
|
||||||
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
outputFile = file("src/main/res/raw/aboutlibraries.json")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val rootModuleDir = rootProject.file("../root-module-manual")
|
||||||
|
val releaseDir = rootProject.file("../release")
|
||||||
|
|
||||||
|
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
|
||||||
|
|
||||||
|
fun registerRootModuleZipTask(
|
||||||
|
name: String,
|
||||||
|
flavor: String,
|
||||||
|
buildType: String
|
||||||
|
) = tasks.register<Zip>(name) {
|
||||||
|
|
||||||
|
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
||||||
|
dependsOn(variantTask)
|
||||||
|
|
||||||
|
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
|
||||||
|
|
||||||
|
from(rootModuleDir)
|
||||||
|
|
||||||
|
duplicatesStrategy = DuplicatesStrategy.WARN
|
||||||
|
|
||||||
|
from(layout.buildDirectory.file(apkPath)) {
|
||||||
|
into("system/priv-app/LibrePods")
|
||||||
|
rename { "LibrePods.apk" }
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
|
||||||
|
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val zipRelease = registerRootModuleZipTask(
|
||||||
|
"zipXposedReleaseModule",
|
||||||
|
"xposed",
|
||||||
|
"release"
|
||||||
|
)
|
||||||
|
|
||||||
|
val zipDebug = registerRootModuleZipTask(
|
||||||
|
"zipXposedDebugModule",
|
||||||
|
"xposed",
|
||||||
|
"debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||||
|
|
||||||
|
dependsOn(
|
||||||
|
zipRelease,
|
||||||
|
zipDebug,
|
||||||
|
"bundleXposedPlayRelease"
|
||||||
|
)
|
||||||
|
|
||||||
|
into(releaseDir)
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
|
||||||
|
include("*.apk")
|
||||||
|
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
|
||||||
|
include("*.apk")
|
||||||
|
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
|
||||||
|
include("*.aab")
|
||||||
|
}
|
||||||
|
|
||||||
|
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
|
||||||
|
include("*.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("packageReleaseArtifacts") {
|
||||||
|
dependsOn(collect)
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+2
@@ -19,3 +19,5 @@
|
|||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MODIFY_PHONE_STATE"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.LOCAL_MAC_ADDRESS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_SCAN"
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
@@ -26,15 +35,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||||
tools:ignore="ScopedStorage" />
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
|
||||||
android:maxSdkVersion="30" />
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
android:maxSdkVersion="30" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".LibrePodsApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
@@ -45,10 +54,9 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.LibrePods"
|
android:theme="@style/Theme.LibrePods"
|
||||||
android:description="@string/app_description"
|
android:description="@string/app_description"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute" >
|
||||||
tools:targetApi="31">
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.NoiseControlWidget"
|
android:name=".presentation.widgets.NoiseControlWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -60,7 +68,7 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widgets.BatteryWidget"
|
android:name=".presentation.widgets.BatteryWidget"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
@@ -114,17 +122,17 @@
|
|||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
<service
|
<!-- <service-->
|
||||||
android:name=".services.AppListenerService"
|
<!-- android:name=".services.AppListenerService"-->
|
||||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
<!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
|
||||||
android:exported="true">
|
<!-- android:exported="true">-->
|
||||||
<intent-filter>
|
<!-- <intent-filter>-->
|
||||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
<!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
|
||||||
</intent-filter>
|
<!-- </intent-filter>-->
|
||||||
<meta-data
|
<!-- <meta-data-->
|
||||||
android:name="android.accessibilityservice"
|
<!-- android:name="android.accessibilityservice"-->
|
||||||
android:resource="@xml/app_listener_service_config" />
|
<!-- android:resource="@xml/app_listener_service_config" />-->
|
||||||
</service>
|
<!-- </service>-->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.BootReceiver"
|
android:name=".receivers.BootReceiver"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|||||||
@@ -3,10 +3,61 @@ cmake_minimum_required(VERSION 3.22.1)
|
|||||||
project("l2c_fcr_hook")
|
project("l2c_fcr_hook")
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
|
|
||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
option(IS_XPOSED "Build Xposed components" OFF)
|
||||||
l2c_fcr_hook.cpp
|
|
||||||
l2c_fcr_hook.h)
|
|
||||||
|
|
||||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
add_library(bluetooth_socket SHARED
|
||||||
|
bluetooth_socket.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_options(bluetooth_socket PRIVATE
|
||||||
|
-O2
|
||||||
|
-fvisibility=hidden
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_options(bluetooth_socket PRIVATE
|
||||||
|
-Wl,--strip-all
|
||||||
|
-Wl,--gc-sections
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(bluetooth_socket
|
||||||
android
|
android
|
||||||
log)
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
if(IS_XPOSED)
|
||||||
|
|
||||||
|
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||||
|
|
||||||
|
add_library(l2c_fcr_hook SHARED
|
||||||
|
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
|
||||||
|
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc32.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_crc64.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_sha256.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
|
||||||
|
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(l2c_fcr_hook PRIVATE
|
||||||
|
${XPOSED_SRC_DIR}
|
||||||
|
${XPOSED_SRC_DIR}/xz
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||||
|
XZ_DEC_X86
|
||||||
|
XZ_DEC_ARM
|
||||||
|
XZ_DEC_ARMTHUMB
|
||||||
|
XZ_DEC_ARM64
|
||||||
|
XZ_DEC_ANY_CHECK
|
||||||
|
XZ_USE_CRC64
|
||||||
|
XZ_USE_SHA256
|
||||||
|
XZ_DEC_CONCATENATED
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(l2c_fcr_hook
|
||||||
|
android
|
||||||
|
log
|
||||||
|
)
|
||||||
|
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <array>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static JavaVM* gVm = nullptr;
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
constexpr auto encryptString(const char (&str)[N], char key) {
|
||||||
|
std::array<char, N> encrypted{};
|
||||||
|
for (size_t i = 0; i < N; i++) {
|
||||||
|
encrypted[i] = str[i] ^ key;
|
||||||
|
}
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<size_t N>
|
||||||
|
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
|
||||||
|
std::string result(N - 1, '\0');
|
||||||
|
for (size_t i = 0; i < N - 1; i++) {
|
||||||
|
result[i] = encrypted[i] ^ key;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define ENC(str) encryptString(str, 0x47)
|
||||||
|
#define DEC(arr) decryptString(arr, 0x47).c_str()
|
||||||
|
|
||||||
|
__attribute__((visibility("hidden")))
|
||||||
|
static JavaVM* getVm() { return gVm; }
|
||||||
|
|
||||||
|
__attribute__((visibility("default")))
|
||||||
|
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||||
|
gVm = vm;
|
||||||
|
|
||||||
|
auto fn = [](void*) -> void* {
|
||||||
|
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
|
||||||
|
constexpr auto c2 = ENC("getRuntime");
|
||||||
|
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
|
||||||
|
constexpr auto c4 = ENC("setHiddenApiExemptions");
|
||||||
|
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
|
||||||
|
constexpr auto c6 = ENC("java/lang/String");
|
||||||
|
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||||
|
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
||||||
|
|
||||||
|
JNIEnv* env;
|
||||||
|
getVm()->AttachCurrentThread(&env, nullptr);
|
||||||
|
|
||||||
|
jclass vmRuntime = env->FindClass(DEC(c1));
|
||||||
|
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
|
||||||
|
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
|
||||||
|
|
||||||
|
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
|
||||||
|
jobjectArray prefixes = env->NewObjectArray(
|
||||||
|
2, env->FindClass(DEC(c6)), nullptr);
|
||||||
|
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
|
||||||
|
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
|
||||||
|
|
||||||
|
env->CallVoidMethod(runtime, setExemptions, prefixes);
|
||||||
|
getVm()->DetachCurrentThread();
|
||||||
|
return nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
pthread_t t;
|
||||||
|
pthread_create(&t, nullptr, fn, nullptr);
|
||||||
|
pthread_join(t, nullptr);
|
||||||
|
return JNI_VERSION_1_6;
|
||||||
|
}
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstring>
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
#include <sys/system_properties.h>
|
|
||||||
#include "l2c_fcr_hook.h"
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdlib>
|
|
||||||
|
|
||||||
#define LOG_TAG "AirPodsHook"
|
|
||||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
|
||||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
|
||||||
|
|
||||||
static HookFunType hook_func = nullptr;
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_REQ 4
|
|
||||||
#define L2CEVT_L2CAP_CONFIG_RSP 15
|
|
||||||
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
typedef struct _BT_HDR {
|
|
||||||
uint16_t event;
|
|
||||||
uint16_t len;
|
|
||||||
uint16_t offset;
|
|
||||||
uint16_t layer_specific;
|
|
||||||
uint8_t data[];
|
|
||||||
} BT_HDR;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint8_t mode;
|
|
||||||
uint8_t tx_win_sz;
|
|
||||||
uint8_t max_transmit;
|
|
||||||
uint16_t rtrans_tout;
|
|
||||||
uint16_t mon_tout;
|
|
||||||
uint16_t mps;
|
|
||||||
} tL2CAP_FCR;
|
|
||||||
|
|
||||||
// Flow spec structure
|
|
||||||
typedef struct {
|
|
||||||
uint8_t qos_present;
|
|
||||||
uint8_t flow_direction;
|
|
||||||
uint8_t service_type;
|
|
||||||
uint32_t token_rate;
|
|
||||||
uint32_t token_bucket_size;
|
|
||||||
uint32_t peak_bandwidth;
|
|
||||||
uint32_t latency;
|
|
||||||
uint32_t delay_variation;
|
|
||||||
} FLOW_SPEC;
|
|
||||||
|
|
||||||
// Configuration info structure
|
|
||||||
typedef struct {
|
|
||||||
uint16_t result;
|
|
||||||
uint16_t mtu_present;
|
|
||||||
uint16_t mtu;
|
|
||||||
uint16_t flush_to_present;
|
|
||||||
uint16_t flush_to;
|
|
||||||
uint16_t qos_present;
|
|
||||||
FLOW_SPEC qos;
|
|
||||||
uint16_t fcr_present;
|
|
||||||
tL2CAP_FCR fcr;
|
|
||||||
uint16_t fcs_present;
|
|
||||||
uint16_t fcs;
|
|
||||||
uint16_t ext_flow_spec_present;
|
|
||||||
FLOW_SPEC ext_flow_spec;
|
|
||||||
} tL2CAP_CFG_INFO;
|
|
||||||
|
|
||||||
// Basic L2CAP link control block
|
|
||||||
typedef struct {
|
|
||||||
bool wait_ack;
|
|
||||||
// Other FCR fields - not needed for our specific hook
|
|
||||||
} tL2C_FCRB;
|
|
||||||
|
|
||||||
// Forward declarations for needed types
|
|
||||||
struct t_l2c_rcb;
|
|
||||||
struct t_l2c_lcb;
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb {
|
|
||||||
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
|
|
||||||
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
|
|
||||||
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
|
|
||||||
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
|
|
||||||
uint16_t local_cid; // Local CID
|
|
||||||
uint16_t remote_cid; // Remote CID
|
|
||||||
uint16_t p_lcb_next; // For linking CCBs to an LCB
|
|
||||||
uint8_t ccb_priority; // Channel priority
|
|
||||||
uint16_t tx_mps; // MPS for outgoing messages
|
|
||||||
uint16_t max_rx_mtu; // Max MTU we will receive
|
|
||||||
// State variables
|
|
||||||
bool in_use; // True when channel active
|
|
||||||
uint8_t chnl_state; // Channel state
|
|
||||||
uint8_t local_id; // Transaction ID for local trans
|
|
||||||
uint8_t remote_id; // Transaction ID for remote
|
|
||||||
uint8_t timer_entry; // Timer entry
|
|
||||||
uint8_t is_flushable; // True if flushable
|
|
||||||
// Configuration variables
|
|
||||||
uint16_t our_cfg_bits; // Bitmap of local config bits
|
|
||||||
uint16_t peer_cfg_bits; // Bitmap of peer config bits
|
|
||||||
uint16_t config_done; // Configuration bitmask
|
|
||||||
uint16_t remote_config_rsp_result; // Remote config response result
|
|
||||||
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
|
|
||||||
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
|
|
||||||
// Additional control fields
|
|
||||||
uint8_t remote_credit_count; // Credits sent to peer
|
|
||||||
tL2C_FCRB fcrb; // FCR info
|
|
||||||
bool ecoc; // Enhanced Credit-based mode
|
|
||||||
} tL2C_CCB;
|
|
||||||
|
|
||||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
|
|
||||||
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
|
|
||||||
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
|
|
||||||
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
|
|
||||||
|
|
||||||
// Add original pointer for BTA_DmSetLocalDiRecord
|
|
||||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
|
|
||||||
|
|
||||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
|
||||||
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
|
|
||||||
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
|
|
||||||
p_ccb->our_cfg.fcr.mode = 0x00;
|
|
||||||
LOGI("Set FCR mode to Basic Mode in outgoing config request");
|
|
||||||
}
|
|
||||||
|
|
||||||
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
|
|
||||||
// Call the original function first to handle the specific code path where the FCR mode is checked
|
|
||||||
original_l2c_csm_config(p_ccb, event, p_data);
|
|
||||||
|
|
||||||
// Check if this happens during CONFIG_RSP event handling
|
|
||||||
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
|
|
||||||
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
|
|
||||||
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
|
|
||||||
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replacement function that does nothing
|
|
||||||
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
|
|
||||||
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
|
|
||||||
// Just return without doing anything
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New loader for SDP hook offset (persist.librepods.sdp_offset)
|
|
||||||
uintptr_t loadSdpOffset() {
|
|
||||||
const char* property_name = "persist.librepods.sdp_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read sdp offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed sdp offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse sdp offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("No sdp offset property present - skipping SDP hook");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
|
|
||||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
|
|
||||||
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
|
|
||||||
if (p_device_info) {
|
|
||||||
p_device_info->vendor = 0x004C;
|
|
||||||
p_device_info->vendor_id_source = 0x0001;
|
|
||||||
}
|
|
||||||
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
|
|
||||||
if (original_BTA_DmSetLocalDiRecord) {
|
|
||||||
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Original BTA_DmSetLocalDiRecord not available");
|
|
||||||
return BTA_FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
|
|
||||||
const char* property_name = "persist.librepods.hook_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read hook offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGI("Using hardcoded fallback offset");
|
|
||||||
return 0x00a55e30;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.cfg_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cCsmConfigOffset() {
|
|
||||||
const char* property_name = "persist.librepods.csm_config_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2c_csm_config offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset() {
|
|
||||||
const char* property_name = "persist.librepods.peer_info_req_offset";
|
|
||||||
char value[PROP_VALUE_MAX] = {0};
|
|
||||||
|
|
||||||
int len = __system_property_get(property_name, value);
|
|
||||||
if (len > 0) {
|
|
||||||
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
|
|
||||||
uintptr_t offset;
|
|
||||||
char* endptr = nullptr;
|
|
||||||
|
|
||||||
const char* parse_start = value;
|
|
||||||
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
|
|
||||||
parse_start = value + 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
errno = 0;
|
|
||||||
offset = strtoul(parse_start, &endptr, 16);
|
|
||||||
|
|
||||||
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
|
|
||||||
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 0 if not found - we'll skip this hook
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t getModuleBase(const char *module_name) {
|
|
||||||
FILE *fp;
|
|
||||||
char line[1024];
|
|
||||||
uintptr_t base_addr = 0;
|
|
||||||
|
|
||||||
fp = fopen("/proc/self/maps", "r");
|
|
||||||
if (!fp) {
|
|
||||||
LOGE("Failed to open /proc/self/maps");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), fp)) {
|
|
||||||
if (strstr(line, module_name)) {
|
|
||||||
char *start_addr_str = line;
|
|
||||||
char *end_addr_str = strchr(line, '-');
|
|
||||||
if (end_addr_str) {
|
|
||||||
*end_addr_str = '\0';
|
|
||||||
base_addr = strtoull(start_addr_str, nullptr, 16);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fclose(fp);
|
|
||||||
return base_addr;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool findAndHookFunction(const char *library_name) {
|
|
||||||
if (!hook_func) {
|
|
||||||
LOGE("Hook function not initialized");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
uintptr_t base_addr = getModuleBase(library_name);
|
|
||||||
if (!base_addr) {
|
|
||||||
LOGE("Failed to get base address of %s", library_name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all offsets from system properties - no hardcoding
|
|
||||||
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
|
|
||||||
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
|
|
||||||
uintptr_t sdp_offset = loadSdpOffset();
|
|
||||||
|
|
||||||
bool success = false;
|
|
||||||
|
|
||||||
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
|
|
||||||
if (l2c_fcr_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
|
|
||||||
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_fcr_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_process_our_cfg_req if offset is available
|
|
||||||
if (l2cu_process_our_cfg_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
|
|
||||||
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_process_our_cfg_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2c_csm_config if offset is available
|
|
||||||
if (l2c_csm_config_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
|
|
||||||
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2c_csm_config_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2c_csm_config, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2c_csm_config");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2c_csm_config hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook l2cu_send_peer_info_req if offset is available
|
|
||||||
if (l2cu_send_peer_info_req_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
|
|
||||||
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
|
|
||||||
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
|
|
||||||
// Continue even if this hook fails
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked l2cu_send_peer_info_req");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sdp_offset > 0) {
|
|
||||||
void* target = reinterpret_cast<void*>(base_addr + sdp_offset);
|
|
||||||
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
|
|
||||||
sdp_offset, (void*)base_addr, target);
|
|
||||||
|
|
||||||
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
|
|
||||||
if (result != 0) {
|
|
||||||
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
|
|
||||||
} else {
|
|
||||||
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
|
||||||
if (strstr(name, "libbluetooth_jni.so")) {
|
|
||||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth JNI library function");
|
|
||||||
}
|
|
||||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
|
||||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
|
||||||
|
|
||||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
|
||||||
if (!hooked) {
|
|
||||||
LOGE("Failed to hook Bluetooth QTI library function");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
|
|
||||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
|
||||||
LOGI("L2C FCR Hook module initialized");
|
|
||||||
|
|
||||||
hook_func = entries->hook_func;
|
|
||||||
|
|
||||||
return on_library_loaded;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
typedef int (*HookFunType)(void *func, void *replace, void **backup);
|
|
||||||
|
|
||||||
typedef int (*UnhookFunType)(void *func);
|
|
||||||
|
|
||||||
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
uint32_t version;
|
|
||||||
HookFunType hook_func;
|
|
||||||
UnhookFunType unhook_func;
|
|
||||||
} NativeAPIEntries;
|
|
||||||
|
|
||||||
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
|
|
||||||
|
|
||||||
typedef struct t_l2c_ccb tL2C_CCB;
|
|
||||||
typedef struct t_l2c_lcb tL2C_LCB;
|
|
||||||
|
|
||||||
uintptr_t loadHookOffset(const char* package_name);
|
|
||||||
uintptr_t getModuleBase(const char *module_name);
|
|
||||||
uintptr_t loadL2cuProcessCfgReqOffset();
|
|
||||||
uintptr_t loadL2cCsmConfigOffset();
|
|
||||||
uintptr_t loadL2cuSendPeerInfoReqOffset();
|
|
||||||
bool findAndHookFunction(const char *library_path);
|
|
||||||
|
|
||||||
#define SDP_MAX_ATTR_LEN 400
|
|
||||||
|
|
||||||
typedef struct t_sdp_di_record {
|
|
||||||
uint16_t vendor;
|
|
||||||
uint16_t vendor_id_source;
|
|
||||||
uint16_t product;
|
|
||||||
uint16_t version;
|
|
||||||
bool primary_record;
|
|
||||||
char client_executable_url[SDP_MAX_ATTR_LEN];
|
|
||||||
char service_description[SDP_MAX_ATTR_LEN];
|
|
||||||
char documentation_url[SDP_MAX_ATTR_LEN];
|
|
||||||
} tSDP_DI_RECORD;
|
|
||||||
|
|
||||||
typedef enum : uint8_t {
|
|
||||||
BTA_SUCCESS = 0, /* Successful operation. */
|
|
||||||
BTA_FAILURE = 1, /* Generic failure. */
|
|
||||||
BTA_PENDING = 2, /* API cannot be completed right now */
|
|
||||||
BTA_BUSY = 3,
|
|
||||||
BTA_NO_RESOURCES = 4,
|
|
||||||
BTA_WRONG_MODE = 5,
|
|
||||||
} tBTA_STATUS;
|
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.screens.Onboarding
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
//import dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
@@ -27,13 +30,11 @@ import android.content.Context
|
|||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -52,6 +53,7 @@ import androidx.compose.animation.slideOutHorizontally
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -63,6 +65,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
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.input.rememberTextFieldState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
@@ -73,7 +76,6 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -85,12 +87,13 @@ 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.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
@@ -103,6 +106,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
@@ -112,44 +116,62 @@ import com.google.accompanist.permissions.isGranted
|
|||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||||
import me.kavishdevar.librepods.screens.CameraControlScreen
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
import me.kavishdevar.librepods.screens.DebugScreen
|
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
|
||||||
import me.kavishdevar.librepods.screens.HeadTrackingScreen
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.screens.HearingAidScreen
|
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||||
import me.kavishdevar.librepods.screens.HearingProtectionScreen
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
import me.kavishdevar.librepods.screens.LongPress
|
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.Onboarding
|
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
|
||||||
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
|
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.RenameScreen
|
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
|
||||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
import me.kavishdevar.librepods.presentation.screens.DebugScreen
|
||||||
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
|
||||||
import me.kavishdevar.librepods.screens.VersionScreen
|
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.viewmodel.AirPodsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.utils.isSupported
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
|
|
||||||
|
//@AndroidEntryPoint
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("l2c_fcr_hook")
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
|
System.loadLibrary("l2c_fcr_hook")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -159,8 +181,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
Main()
|
Main()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -195,82 +215,258 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
super.onStop()
|
super.onStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
handleIncomingIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIncomingIntent(intent: Intent) {
|
|
||||||
val data: Uri? = intent.data
|
|
||||||
|
|
||||||
if (data != null && data.scheme == "librepods") {
|
|
||||||
when (data.host) {
|
|
||||||
"add-magic-keys" -> {
|
|
||||||
val queryParams = data.queryParameterNames
|
|
||||||
queryParams.forEach { param ->
|
|
||||||
val value = data.getQueryParameter(param)
|
|
||||||
Log.d("LibrePods", "Parameter: $param = $value")
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAddMagicKeys(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleAddMagicKeys(uri: Uri) {
|
|
||||||
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
|
|
||||||
val irkHex = uri.getQueryParameter("irk")
|
|
||||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (irkHex != null && validateHexInput(irkHex)) {
|
|
||||||
val irkBytes = hexStringToByteArray(irkHex)
|
|
||||||
val irkBase64 = Base64.encode(irkBytes)
|
|
||||||
sharedPreferences.edit {putString("IRK", irkBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
|
||||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
|
||||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
|
||||||
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateHexInput(input: String): Boolean {
|
|
||||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
|
||||||
return hexPattern.matches(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
|
||||||
val result = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
|
||||||
result[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun Main() {
|
fun Main() {
|
||||||
val isConnected = remember { mutableStateOf(false) }
|
|
||||||
val isRemotelyConnected = remember { mutableStateOf(false) }
|
|
||||||
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
|
||||||
|
val 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)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier,
|
||||||
|
verticalArrangement = Arrangement
|
||||||
|
.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.not_supported),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.check_the_repository_for_more_info),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StyledButton(
|
||||||
|
onClick = { showDialog.value = true },
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
isInteractive = false,
|
||||||
|
surfaceColor = if (isDarkTheme) Color(0xFF862424) else Color(0xFFC94646)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bypass_compatibility_check),
|
||||||
|
style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
DeviceInfoCard()
|
||||||
|
AppInfoCard()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
backdrop = backdrop
|
||||||
|
// hazeState = hazeState
|
||||||
|
)
|
||||||
|
|
||||||
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
|
StyledBottomSheet(
|
||||||
|
visible = showPlayBypassVisible.value,
|
||||||
|
onDismiss = {
|
||||||
|
showPlayBypassVisible.value = false
|
||||||
|
showDialog.value = true
|
||||||
|
},
|
||||||
|
backdrop = backdrop
|
||||||
|
) { innerBackdrop, _ ->
|
||||||
|
val contentColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
var acknowledged by remember { mutableStateOf(false) }
|
||||||
|
val inputState = rememberTextFieldState("")
|
||||||
|
|
||||||
|
val isValid = acknowledged && inputState.text.trim() == "OK"
|
||||||
|
|
||||||
|
val sfPro = FontFamily(Font(R.font.sf_pro))
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
inputState = inputState,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
placeholder = stringResource(R.string.type_ok_to_continue, "OK")
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = { showPlayBypassVisible.value = false },
|
||||||
|
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 = {
|
||||||
|
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 = innerBackdrop,
|
||||||
|
isInteractive = isValid,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = isValid,
|
||||||
|
surfaceColor = if (isDarkTheme) 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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isConnected = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
|
val overlaySkipped = remember {
|
||||||
|
mutableStateOf(
|
||||||
|
context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
.getBoolean("overlay_permission_skipped", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
listOf(
|
listOf(
|
||||||
@@ -297,23 +493,33 @@ fun Main() {
|
|||||||
val permissionState = rememberMultiplePermissionsState(
|
val permissionState = rememberMultiplePermissionsState(
|
||||||
permissions = allPermissions
|
permissions = allPermissions
|
||||||
)
|
)
|
||||||
|
|
||||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||||
|
|
||||||
|
val airPodsViewModel = remember(airPodsService.value) {
|
||||||
|
airPodsService.value?.let { service ->
|
||||||
|
AirPodsViewModel(
|
||||||
|
service = service,
|
||||||
|
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||||
|
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||||
|
appContext = context.applicationContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
canDrawOverlays = Settings.canDrawOverlays(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
) {
|
||||||
){
|
|
||||||
val backButtonBackdrop = rememberLayerBackdrop()
|
val backButtonBackdrop = rememberLayerBackdrop()
|
||||||
Box (
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
|
||||||
@@ -321,129 +527,126 @@ fun Main() {
|
|||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (hookAvailable) "settings" else "onboarding",
|
startDestination = "settings",
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { it },
|
initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { -it/4 },
|
targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { -it/4 },
|
initialOffsetX = { -it / 4 },
|
||||||
animationSpec = tween(durationMillis = 300)
|
animationSpec = tween(durationMillis = 300)
|
||||||
) // + fadeIn(animationSpec = tween(durationMillis = 300))
|
)
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { it },
|
targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
|
||||||
animationSpec = tween(durationMillis = 300)
|
)
|
||||||
) // + fadeOut(animationSpec = tween(durationMillis = 150))
|
}) {
|
||||||
}
|
|
||||||
) {
|
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
if (airPodsService.value != null) {
|
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
|
||||||
AirPodsSettingsScreen(
|
|
||||||
dev = airPodsService.value?.device,
|
|
||||||
service = airPodsService.value!!,
|
|
||||||
navController = navController,
|
|
||||||
isConnected = isConnected.value,
|
|
||||||
isRemotelyConnected = isRemotelyConnected.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
composable("debug") {
|
composable("debug") {
|
||||||
DebugScreen(navController = navController)
|
DebugScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("long_press/{bud}") { navBackStackEntry ->
|
composable("long_press/{bud}") { navBackStackEntry ->
|
||||||
LongPress(
|
if (airPodsViewModel != null) LongPress(
|
||||||
navController = navController,
|
viewModel = airPodsViewModel,
|
||||||
name = navBackStackEntry.arguments?.getString("bud")!!
|
name = navBackStackEntry.arguments?.getString("bud")!!,
|
||||||
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("rename") {
|
composable("rename") {
|
||||||
RenameScreen(navController)
|
if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("app_settings") {
|
composable("app_settings") {
|
||||||
AppSettingsScreen(navController)
|
val appSettingsViewModel: AppSettingsViewModel = viewModel()
|
||||||
|
AppSettingsScreen(navController, appSettingsViewModel)
|
||||||
}
|
}
|
||||||
composable("troubleshooting") {
|
composable("troubleshooting") {
|
||||||
TroubleshootingScreen(navController)
|
TroubleshootingScreen(navController)
|
||||||
}
|
}
|
||||||
composable("head_tracking") {
|
composable("head_tracking") {
|
||||||
HeadTrackingScreen()
|
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
|
||||||
}
|
|
||||||
composable("onboarding") {
|
|
||||||
Onboarding(navController, context)
|
|
||||||
}
|
}
|
||||||
composable("accessibility") {
|
composable("accessibility") {
|
||||||
AccessibilitySettingsScreen(navController)
|
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("transparency_customization") {
|
composable("transparency_customization") {
|
||||||
TransparencySettingsScreen(navController)
|
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_aid") {
|
composable("hearing_aid") {
|
||||||
HearingAidScreen(navController)
|
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("hearing_aid_adjustments") {
|
composable("hearing_aid_adjustments") {
|
||||||
HearingAidAdjustmentsScreen(navController)
|
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("adaptive_strength") {
|
composable("adaptive_strength") {
|
||||||
AdaptiveStrengthScreen(navController)
|
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
|
||||||
}
|
}
|
||||||
composable("camera_control") {
|
composable("camera_control") {
|
||||||
CameraControlScreen(navController)
|
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("open_source_licenses") {
|
composable("open_source_licenses") {
|
||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
UpdateHearingTestScreen(navController)
|
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
VersionScreen(navController)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("hearing_protection") {
|
composable("hearing_protection") {
|
||||||
HearingProtectionScreen(navController)
|
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
|
||||||
|
}
|
||||||
|
composable("purchase_screen") {
|
||||||
|
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||||
|
PurchaseScreen(purchaseViewModel, navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val showBackButton = remember{ mutableStateOf(false) }
|
val showBackButton = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||||
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
|
showBackButton.value =
|
||||||
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
|
destination.route != "settings" // && destination.route != "onboarding"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackButton.value,
|
visible = showBackButton.value,
|
||||||
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
|
enter = fadeIn(animationSpec = tween()) + scaleIn(
|
||||||
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
initialScale = 0f,
|
||||||
|
animationSpec = tween()
|
||||||
|
),
|
||||||
|
exit = fadeOut(animationSpec = tween()) + scaleOut(
|
||||||
|
targetScale = 0.5f,
|
||||||
|
animationSpec = tween(100)
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(
|
.padding(
|
||||||
start = 8.dp,
|
start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
||||||
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = { navController.popBackStack() },
|
onClick = { navController.popBackStack() },
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isSystemInDarkTheme(),
|
backdrop = backButtonBackdrop
|
||||||
backdrop = backButtonBackdrop
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
||||||
|
|
||||||
serviceConnection = remember {
|
serviceConnection = remember {
|
||||||
object : ServiceConnection {
|
object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
@@ -457,17 +660,20 @@ fun Main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(
|
||||||
|
Intent(context, AirPodsService::class.java),
|
||||||
|
serviceConnection,
|
||||||
|
Context.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
|
||||||
if (airPodsService.value?.isConnectedLocally == true) {
|
if (airPodsService.value?.isConnected() == true) {
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PermissionsScreen(
|
PermissionsScreen(
|
||||||
permissionState = permissionState,
|
permissionState = permissionState,
|
||||||
canDrawOverlays = canDrawOverlays,
|
canDrawOverlays = canDrawOverlays,
|
||||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
|
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,13 +696,9 @@ fun PermissionsScreen(
|
|||||||
|
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
val pulseScale by infiniteTransition.animateFloat(
|
val pulseScale by infiniteTransition.animateFloat(
|
||||||
initialValue = 1f,
|
initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
|
||||||
targetValue = 1.05f,
|
animation = tween(1000), repeatMode = RepeatMode.Reverse
|
||||||
animationSpec = infiniteRepeatable(
|
), label = "pulse scale"
|
||||||
animation = tween(1000),
|
|
||||||
repeatMode = RepeatMode.Reverse
|
|
||||||
),
|
|
||||||
label = "pulse scale"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -504,18 +706,15 @@ fun PermissionsScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(180.dp),
|
.height(180.dp), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "\uDBC2\uDEB7",
|
text = "\uDBC2\uDEB7", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 48.sp,
|
fontSize = 48.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -551,29 +750,25 @@ fun PermissionsScreen(
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Permission Required",
|
text = "Permission Required", style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permissions_required),
|
text = stringResource(R.string.permissions_required), style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor.copy(alpha = 0.7f),
|
color = textColor.copy(alpha = 0.7f),
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
), modifier = Modifier.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
@@ -746,8 +941,7 @@ fun PermissionCard(
|
|||||||
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
|
||||||
alpha = 0.15f
|
alpha = 0.15f
|
||||||
)
|
)
|
||||||
),
|
), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
@@ -763,8 +957,7 @@ fun PermissionCard(
|
|||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -773,8 +966,7 @@ fun PermissionCard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description, style = TextStyle(
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
@@ -791,11 +983,8 @@ fun PermissionCard(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isGranted) "✓" else "!",
|
text = if (isGranted) "✓" else "!", style = TextStyle(
|
||||||
style = TextStyle(
|
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = Color.White
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
|
||||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
|
||||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
import me.kavishdevar.librepods.presentation.components.IconAreaSize
|
||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
object BillingManager {
|
||||||
|
lateinit var provider: BillingProvider
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface BillingProvider {
|
||||||
|
val isPremium: StateFlow<Boolean>
|
||||||
|
val price: StateFlow<String>
|
||||||
|
fun purchase(activity: Activity)
|
||||||
|
fun queryPurchases()
|
||||||
|
fun restorePurchases()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
|
||||||
|
object BillingProviderFactory {
|
||||||
|
|
||||||
|
fun create(context: Context): BillingProvider {
|
||||||
|
return if (BuildConfig.PLAY_BUILD) {
|
||||||
|
PlayBillingProvider(context)
|
||||||
|
} else {
|
||||||
|
FOSSBillingProvider(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||||
|
private val _isPremium = MutableStateFlow(false)
|
||||||
|
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||||
|
|
||||||
|
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
|
||||||
|
override val price: StateFlow<String> = _price
|
||||||
|
|
||||||
|
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var purchaseJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
queryPurchases()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun purchase(activity: Activity) {
|
||||||
|
activity.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri())
|
||||||
|
)
|
||||||
|
|
||||||
|
purchaseJob?.cancel()
|
||||||
|
|
||||||
|
purchaseJob = scope.launch {
|
||||||
|
delay(5_000)
|
||||||
|
_isPremium.value = true
|
||||||
|
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryPurchases() {
|
||||||
|
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
if (stored != _isPremium.value) {
|
||||||
|
_isPremium.value = stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun restorePurchases() {
|
||||||
|
_isPremium.value = true
|
||||||
|
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.billing
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.billingclient.api.AcknowledgePurchaseParams
|
||||||
|
import com.android.billingclient.api.BillingClient
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener
|
||||||
|
import com.android.billingclient.api.BillingFlowParams
|
||||||
|
import com.android.billingclient.api.BillingResult
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams
|
||||||
|
import com.android.billingclient.api.ProductDetails
|
||||||
|
import com.android.billingclient.api.Purchase
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams
|
||||||
|
import com.android.billingclient.api.QueryPurchasesParams
|
||||||
|
import com.android.billingclient.api.acknowledgePurchase
|
||||||
|
import com.android.billingclient.api.queryProductDetails
|
||||||
|
import com.android.billingclient.api.queryPurchasesAsync
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
const val TAG = "PlayBillingProvider"
|
||||||
|
|
||||||
|
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||||
|
|
||||||
|
class PlayBillingProvider(
|
||||||
|
context: Context
|
||||||
|
) : BillingProvider, PurchasesUpdatedListener {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
private val _isPremium = MutableStateFlow(false)
|
||||||
|
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||||
|
|
||||||
|
private val _price = MutableStateFlow("unknown")
|
||||||
|
override val price: StateFlow<String> = _price
|
||||||
|
|
||||||
|
|
||||||
|
private var productDetails: ProductDetails? = null
|
||||||
|
|
||||||
|
private val billingClient = BillingClient.newBuilder(context)
|
||||||
|
.setListener(this)
|
||||||
|
.enablePendingPurchases(
|
||||||
|
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
init {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect() {
|
||||||
|
billingClient.startConnection(object : BillingClientStateListener {
|
||||||
|
override fun onBillingSetupFinished(result: BillingResult) {
|
||||||
|
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
scope.launch {
|
||||||
|
queryProductDetails()
|
||||||
|
queryExistingPurchases()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBillingServiceDisconnected() {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryProductDetails() {
|
||||||
|
val params = QueryProductDetailsParams.newBuilder()
|
||||||
|
.setProductList(
|
||||||
|
listOf(
|
||||||
|
QueryProductDetailsParams.Product.newBuilder()
|
||||||
|
.setProductId(PREMIUM_PRODUCT_ID)
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.queryProductDetails(params)
|
||||||
|
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
||||||
|
productDetails = result.productDetailsList?.firstOrNull()
|
||||||
|
Log.d(TAG, "Product loaded: ${productDetails?.name}")
|
||||||
|
val priceString = productDetails
|
||||||
|
?.oneTimePurchaseOfferDetails
|
||||||
|
?.formattedPrice
|
||||||
|
|
||||||
|
if (priceString != null) {
|
||||||
|
_price.value = priceString
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryExistingPurchases() {
|
||||||
|
val result = billingClient.queryPurchasesAsync(
|
||||||
|
QueryPurchasesParams.newBuilder()
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
processPurchases(result.purchasesList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun purchase(activity: Activity) {
|
||||||
|
val details = productDetails ?: run {
|
||||||
|
Log.e(TAG, "Product details not loaded yet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val billingFlowParams = BillingFlowParams.newBuilder()
|
||||||
|
.setProductDetailsParamsList(
|
||||||
|
listOf(
|
||||||
|
BillingFlowParams.ProductDetailsParams.newBuilder()
|
||||||
|
.setProductDetails(details)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
|
||||||
|
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
|
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
||||||
|
when (result.responseCode) {
|
||||||
|
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
|
||||||
|
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
|
||||||
|
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPurchases(purchases: List<Purchase>) {
|
||||||
|
val hasPremium = purchases.any {
|
||||||
|
it.products.contains(PREMIUM_PRODUCT_ID) &&
|
||||||
|
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||||
|
}
|
||||||
|
|
||||||
|
// val purchase = purchases.find {
|
||||||
|
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (purchase != null) {
|
||||||
|
// val consumeParams = ConsumeParams.newBuilder()
|
||||||
|
// .setPurchaseToken(purchase.purchaseToken)
|
||||||
|
// .build()
|
||||||
|
// scope.launch {
|
||||||
|
// billingClient.consumeAsync(consumeParams) { _, _ ->}
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
_isPremium.value = hasPremium
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
purchases
|
||||||
|
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
||||||
|
.forEach { acknowledge(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acknowledge(purchase: Purchase) {
|
||||||
|
val params = AcknowledgePurchaseParams.newBuilder()
|
||||||
|
.setPurchaseToken(purchase.purchaseToken)
|
||||||
|
.build()
|
||||||
|
val result = billingClient.acknowledgePurchase(params)
|
||||||
|
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||||
|
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryPurchases() {
|
||||||
|
scope.launch {
|
||||||
|
queryExistingPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun restorePurchases() {
|
||||||
|
queryPurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-153
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
@@ -55,13 +55,13 @@ class AACPManager {
|
|||||||
const val TIPI_3: Byte = 0x0C // Don't know this one
|
const val TIPI_3: Byte = 0x0C // Don't know this one
|
||||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||||
|
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
data class ControlCommandStatus(
|
data class ControlCommandStatus(
|
||||||
val identifier: ControlCommandIdentifiers,
|
val identifier: ControlCommandIdentifiers, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -84,42 +84,31 @@ class AACPManager {
|
|||||||
|
|
||||||
// @Suppress("unused")
|
// @Suppress("unused")
|
||||||
enum class ControlCommandIdentifiers(val value: Byte) {
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
MIC_MODE(0x01),
|
MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
|
||||||
BUTTON_SEND_MODE(0x05),
|
0x15
|
||||||
VOICE_TRIGGER(0x12),
|
),
|
||||||
SINGLE_CLICK_MODE(0x14),
|
CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
|
||||||
DOUBLE_CLICK_MODE(0x15),
|
0x1A
|
||||||
CLICK_HOLD_MODE(0x16),
|
),
|
||||||
DOUBLE_CLICK_INTERVAL(0x17),
|
ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
|
||||||
CLICK_HOLD_INTERVAL(0x18),
|
0x1E
|
||||||
LISTENING_MODE_CONFIGS(0x1A),
|
),
|
||||||
ONE_BUD_ANC_MODE(0x1B),
|
CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
|
||||||
CROWN_ROTATION_DIRECTION(0x1C),
|
0x25
|
||||||
LISTENING_MODE(0x0D),
|
),
|
||||||
AUTO_ANSWER_MODE(0x1E),
|
ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
|
||||||
CHIME_VOLUME(0x1F),
|
0x28
|
||||||
VOLUME_SWIPE_INTERVAL(0x23),
|
),
|
||||||
CALL_MANAGEMENT_CONFIG(0x24),
|
SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
|
||||||
VOLUME_SWIPE_MODE(0x25),
|
0x30
|
||||||
ADAPTIVE_VOLUME_CONFIG(0x26),
|
),
|
||||||
SOFTWARE_MUTE_CONFIG(0x27),
|
IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
|
||||||
CONVERSATION_DETECT_CONFIG(0x28),
|
0x34
|
||||||
SSL(0x29),
|
),
|
||||||
HEARING_AID(0x2C),
|
STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
|
||||||
AUTO_ANC_STRENGTH(0x2E),
|
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
|
||||||
HPS_GAIN_SWIPE(0x2F),
|
0x37
|
||||||
HRM_STATE(0x30),
|
),
|
||||||
IN_CASE_TONE_CONFIG(0x31),
|
|
||||||
SIRI_MULTITONE_CONFIG(0x32),
|
|
||||||
HEARING_ASSIST_CONFIG(0x33),
|
|
||||||
ALLOW_OFF_OPTION(0x34),
|
|
||||||
STEM_CONFIG(0x39),
|
|
||||||
SLEEP_DETECTION_CONFIG(0x35),
|
|
||||||
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
|
|
||||||
EAR_DETECTION_CONFIG(0x0A),
|
|
||||||
AUTOMATIC_CONNECTION_CONFIG(0x20),
|
|
||||||
OWNS_CONNECTION(0x06),
|
|
||||||
PPE_TOGGLE_CONFIG(0x37),
|
|
||||||
PPE_CAP_LEVEL_CONFIG(0x38);
|
PPE_CAP_LEVEL_CONFIG(0x38);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -129,59 +118,44 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class ProximityKeyType(val value: Byte) {
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
IRK(0x01),
|
IRK(0x01), ENC_KEY(0x04);
|
||||||
ENC_KEY(0x04);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): ProximityKeyType =
|
fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
|
||||||
ProximityKeyType.entries.find { it.value == byte }
|
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressType(val value: Byte) {
|
enum class StemPressType(val value: Byte) {
|
||||||
SINGLE_PRESS(0x05),
|
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
|
||||||
DOUBLE_PRESS(0x06),
|
|
||||||
TRIPLE_PRESS(0x07),
|
|
||||||
LONG_PRESS(0x08);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressType? =
|
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class StemPressBudType(val value: Byte) {
|
enum class StemPressBudType(val value: Byte) {
|
||||||
LEFT(0x01),
|
LEFT(0x01), RIGHT(0x02);
|
||||||
RIGHT(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): StemPressBudType? =
|
fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AudioSourceType(val value: Byte) {
|
enum class AudioSourceType(val value: Byte) {
|
||||||
NONE(0x00),
|
NONE(0x00), CALL(0x01), MEDIA(0x02);
|
||||||
CALL(0x01),
|
|
||||||
MEDIA(0x02);
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromByte(byte: Byte): AudioSourceType? =
|
fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
|
||||||
entries.find { it.value == byte }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class AudioSource(
|
data class AudioSource(
|
||||||
val mac: String,
|
val mac: String, val type: AudioSourceType
|
||||||
val type: AudioSourceType
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ConnectedDevice(
|
data class ConnectedDevice(
|
||||||
val mac: String,
|
val mac: String, val info1: Byte, val info2: Byte, var type: String?
|
||||||
val info1: Byte,
|
|
||||||
val info2: Byte,
|
|
||||||
var type: String?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AirPodsInformation(
|
data class AirPodsInformation(
|
||||||
@@ -216,7 +190,7 @@ class AACPManager {
|
|||||||
var audioSource: AudioSource? = null
|
var audioSource: AudioSource? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqData = FloatArray(8) { 0.0f }
|
var eqData = FloatArray(8)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var eqOnPhone: Boolean = false
|
var eqOnPhone: Boolean = false
|
||||||
@@ -230,8 +204,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setControlCommandStatusValue(
|
private fun setControlCommandStatusValue(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||||
value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
val existingStatus = getControlCommandStatus(identifier)
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
if (existingStatus == value) {
|
if (existingStatus == value) {
|
||||||
@@ -265,6 +238,7 @@ class AACPManager {
|
|||||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||||
fun onShowNearbyUI(sender: String)
|
fun onShowNearbyUI(sender: String)
|
||||||
|
fun onEQPacketReceived(eqData: FloatArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
@@ -287,15 +261,13 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun registerControlCommandListener(
|
fun registerControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterControlCommandListener(
|
fun unregisterControlCommandListener(
|
||||||
identifier: ControlCommandIdentifiers,
|
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
|
||||||
callback: ControlCommandListener
|
|
||||||
) {
|
) {
|
||||||
controlCommandListeners[identifier]?.remove(callback)
|
controlCommandListeners[identifier]?.remove(callback)
|
||||||
}
|
}
|
||||||
@@ -330,8 +302,7 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, value)
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
|
||||||
value
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
@@ -340,16 +311,14 @@ class AACPManager {
|
|||||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
|
||||||
byteArrayOf(value)
|
|
||||||
)
|
)
|
||||||
return sendDataPacket(controlPacket)
|
return sendDataPacket(controlPacket)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
val controlPacket = createControlCommandPacket(
|
val controlPacket = createControlCommandPacket(
|
||||||
identifier,
|
identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
|
||||||
)
|
)
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
@@ -369,8 +338,7 @@ class AACPManager {
|
|||||||
|
|
||||||
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
if (data.size < 4) {
|
if (data.size < 4) {
|
||||||
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
@@ -395,14 +363,18 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
val key = ByteArray(keyLength)
|
val key = ByteArray(keyLength)
|
||||||
System.arraycopy(data, offset, key, 0, keyLength)
|
System.arraycopy(data, offset, key, 0, keyLength)
|
||||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
try {
|
||||||
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(
|
||||||
|
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
offset += keyLength
|
offset += keyLength
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
||||||
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
|
key.joinToString(" ") { "%02X".format(it) }
|
||||||
key.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@@ -422,26 +394,21 @@ class AACPManager {
|
|||||||
fun receivePacket(packet: ByteArray) {
|
fun receivePacket(packet: ByteArray) {
|
||||||
if (!packet.toHexString().startsWith("04000400")) {
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet does not start with expected header: ${
|
||||||
"Received packet does not start with expected header: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packet.size < 6) {
|
if (packet.size < 6) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
||||||
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val opcode = packet[4]
|
when (val opcode = packet[4]) {
|
||||||
|
|
||||||
when (opcode) {
|
|
||||||
Opcodes.BATTERY_INFO -> {
|
Opcodes.BATTERY_INFO -> {
|
||||||
callback?.onBatteryInfoReceived(packet)
|
callback?.onBatteryInfoReceived(packet)
|
||||||
}
|
}
|
||||||
@@ -456,10 +423,9 @@ class AACPManager {
|
|||||||
TAG,
|
TAG,
|
||||||
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
"Control command received: ${controlCommand.identifier.toHexString()} - ${
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
}"
|
}")
|
||||||
)
|
|
||||||
Log.d(
|
val controlCommandListText = try {
|
||||||
TAG, "Control command list is now: ${
|
|
||||||
controlCommandStatusList.joinToString(", ") { it ->
|
controlCommandStatusList.joinToString(", ") { it ->
|
||||||
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
|
||||||
it.value.joinToString(
|
it.value.joinToString(
|
||||||
@@ -467,12 +433,19 @@ class AACPManager {
|
|||||||
) { "%02X".format(it) }
|
) { "%02X".format(it) }
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
}")
|
} catch (e: Exception) {
|
||||||
|
e.message
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
TAG, "Control command list is now: $controlCommandListText"
|
||||||
|
)
|
||||||
|
|
||||||
val controlCommandIdentifier =
|
val controlCommandIdentifier =
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
if (controlCommandIdentifier != null) {
|
if (controlCommandIdentifier != null) {
|
||||||
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
|
||||||
listener.onControlCommandReceived(controlCommand)
|
listener.onControlCommandReceived(controlCommand)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -500,13 +473,11 @@ class AACPManager {
|
|||||||
Opcodes.HEADTRACKING -> {
|
Opcodes.HEADTRACKING -> {
|
||||||
if (packet.size < 70) {
|
if (packet.size < 70) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG, "Received HEADTRACKING packet too short: ${
|
||||||
"Received HEADTRACKING packet too short: ${
|
packet.joinToString(" ") {
|
||||||
packet.joinToString(" ") {
|
"%02X".format(it)
|
||||||
"%02X".format(it)
|
}
|
||||||
}
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
callback?.onHeadTrackingReceived(packet)
|
callback?.onHeadTrackingReceived(packet)
|
||||||
@@ -538,7 +509,8 @@ class AACPManager {
|
|||||||
|
|
||||||
Opcodes.SMART_ROUTING_RESP -> {
|
Opcodes.SMART_ROUTING_RESP -> {
|
||||||
val packetString = packet.decodeToString()
|
val packetString = packet.decodeToString()
|
||||||
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
val sender =
|
||||||
|
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
|
||||||
|
|
||||||
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
|
||||||
// val nameStartIndex = packetString.indexOf("btName") + 8
|
// val nameStartIndex = packetString.indexOf("btName") + 8
|
||||||
@@ -558,9 +530,15 @@ class AACPManager {
|
|||||||
} else if ("Android" in packetString) {
|
} else if ("Android" in packetString) {
|
||||||
connectedDevices.find { it.mac == sender }?.type = "Android"
|
connectedDevices.find { it.mac == sender }?.type = "Android"
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
|
||||||
|
)
|
||||||
if (packetString.contains("SetOwnershipToFalse")) {
|
if (packetString.contains("SetOwnershipToFalse")) {
|
||||||
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
|
callback?.onOwnershipToFalseRequest(
|
||||||
|
sender,
|
||||||
|
packetString.contains("ReverseBannerTapped")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (packetString.contains("ShowNearbyUI")) {
|
if (packetString.contains("ShowNearbyUI")) {
|
||||||
callback?.onShowNearbyUI(sender)
|
callback?.onShowNearbyUI(sender)
|
||||||
@@ -585,25 +563,33 @@ class AACPManager {
|
|||||||
|
|
||||||
eqOnMedia = (packet[10] == 0x01.toByte())
|
eqOnMedia = (packet[10] == 0x01.toByte())
|
||||||
eqOnPhone = (packet[11] == 0x01.toByte())
|
eqOnPhone = (packet[11] == 0x01.toByte())
|
||||||
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird.
|
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
|
||||||
// the EQs are little endian floats
|
// the EQs are little endian floats
|
||||||
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
val eq1 =
|
||||||
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
|
||||||
|
|
||||||
// for now, taking just the first EQ
|
// for now, taking just the first EQ
|
||||||
eqData = FloatArray(8) { i -> eq1.get(i) }
|
eqData = FloatArray(8) { i -> eq1.get(i) }
|
||||||
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
|
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||||
|
)
|
||||||
|
|
||||||
|
callback?.onEQPacketReceived(eqData)
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.INFORMATION -> {
|
Opcodes.INFORMATION -> {
|
||||||
Log.e(TAG, "Parsing Information Packet")
|
Log.d(TAG, "Parsing Information Packet")
|
||||||
val information = parseInformationPacket(packet)
|
val information = parseInformationPacket(packet)
|
||||||
callback?.onDeviceInformationReceived(information)
|
callback?.onDeviceInformationReceived(information)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
|
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,10 +619,22 @@ class AACPManager {
|
|||||||
|
|
||||||
fun createHandshakePacket(): ByteArray {
|
fun createHandshakePacket(): ByteArray {
|
||||||
return byteArrayOf(
|
return byteArrayOf(
|
||||||
0x00, 0x00, 0x04, 0x00,
|
0x00,
|
||||||
0x01, 0x00, 0x02, 0x00,
|
0x00,
|
||||||
0x00, 0x00, 0x00, 0x00,
|
0x04,
|
||||||
0x00, 0x00, 0x00, 0x00
|
0x00,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,17 +780,31 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
||||||
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
|
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
|
||||||
|
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||||
|
)
|
||||||
|
) {
|
||||||
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
||||||
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
|
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
|
||||||
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
|
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
|
||||||
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
|
return sendDataPacket(
|
||||||
|
createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress,
|
||||||
|
targetMacAddress
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
|
fun createMediaInformationNewDevicePacket(
|
||||||
|
selfMacAddress: String,
|
||||||
|
targetMacAddress: String
|
||||||
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(116)
|
val buffer = ByteBuffer.allocate(116)
|
||||||
buffer.put(
|
buffer.put(
|
||||||
@@ -881,17 +893,13 @@ class AACPManager {
|
|||||||
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
Log.d(TAG, "Sending Media Information packet to $targetMac")
|
||||||
return sendDataPacket(
|
return sendDataPacket(
|
||||||
createMediaInformationPacket(
|
createMediaInformationPacket(
|
||||||
selfMacAddress,
|
selfMacAddress, targetMac, streamingState
|
||||||
targetMac,
|
|
||||||
streamingState
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createMediaInformationPacket(
|
fun createMediaInformationPacket(
|
||||||
selfMacAddress: String,
|
selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
|
||||||
targetMacAddress: String,
|
|
||||||
streamingState: Boolean = true
|
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
|
||||||
val buffer = ByteBuffer.allocate(138)
|
val buffer = ByteBuffer.allocate(138)
|
||||||
@@ -906,7 +914,7 @@ class AACPManager {
|
|||||||
)
|
)
|
||||||
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
|
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
|
||||||
buffer.put("PlayingApp".toByteArray())
|
buffer.put("PlayingApp".toByteArray())
|
||||||
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator
|
buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
|
||||||
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
|
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
|
||||||
buffer.put(byteArrayOf(0x52)) // 'R'
|
buffer.put(byteArrayOf(0x52)) // 'R'
|
||||||
buffer.put("HostStreamingState".toByteArray())
|
buffer.put("HostStreamingState".toByteArray())
|
||||||
@@ -924,7 +932,7 @@ class AACPManager {
|
|||||||
buffer.put("AudioCategory".toByteArray())
|
buffer.put("AudioCategory".toByteArray())
|
||||||
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
|
||||||
|
|
||||||
return opcode+buffer.array()
|
return opcode + buffer.array()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
|
||||||
@@ -1006,9 +1014,15 @@ class AACPManager {
|
|||||||
|
|
||||||
|
|
||||||
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
|
||||||
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
|
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
|
||||||
|
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||||
|
)
|
||||||
|
) {
|
||||||
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
// throw IllegalArgumentException("MAC address must be 6 bytes")
|
||||||
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
|
||||||
@@ -1042,8 +1056,7 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ControlCommand(
|
data class ControlCommand(
|
||||||
val identifier: Byte,
|
val identifier: Byte, val value: ByteArray
|
||||||
val value: ByteArray
|
|
||||||
) {
|
) {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@@ -1095,10 +1108,8 @@ class AACPManager {
|
|||||||
triplePressCustomized: Boolean = false,
|
triplePressCustomized: Boolean = false,
|
||||||
longPressCustomized: Boolean = false
|
longPressCustomized: Boolean = false
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val value = ((if (singlePressCustomized) 0x01 else 0) or
|
val value =
|
||||||
(if (doublePressCustomized) 0x02 else 0) or
|
((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
|
||||||
(if (triplePressCustomized) 0x04 else 0) or
|
|
||||||
(if (longPressCustomized) 0x08 else 0)).toByte()
|
|
||||||
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
||||||
return sendControlCommand(
|
return sendControlCommand(
|
||||||
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
||||||
@@ -1113,19 +1124,18 @@ class AACPManager {
|
|||||||
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
val controlCommand = ControlCommand.fromByteArray(packet)
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
|
||||||
"Control command: ${controlCommand.identifier.toHexString()} - ${
|
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
||||||
controlCommand.value.joinToString(" ") { "%02X".format(it) }
|
}")
|
||||||
}"
|
|
||||||
)
|
|
||||||
setControlCommandStatusValue(
|
setControlCommandStatusValue(
|
||||||
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
controlCommand.value
|
controlCommand.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket()
|
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
||||||
if (socket?.isConnected == true) {
|
|
||||||
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
socket.outputStream?.flush()
|
socket.outputStream?.flush()
|
||||||
return true
|
return true
|
||||||
@@ -1202,7 +1212,10 @@ class AACPManager {
|
|||||||
var offset = 9
|
var offset = 9
|
||||||
for (i in 0 until deviceCount) {
|
for (i in 0 until deviceCount) {
|
||||||
if (offset + 8 > data.size) {
|
if (offset + 8 > data.size) {
|
||||||
Log.w(TAG, "Data array too short to parse all connected devices, returning what we have")
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Data array too short to parse all connected devices, returning what we have"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val macBytes = data.sliceArray(offset until offset + 6)
|
val macBytes = data.sliceArray(offset until offset + 6)
|
||||||
@@ -1216,6 +1229,7 @@ class AACPManager {
|
|||||||
|
|
||||||
return devices
|
return devices
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendSomePacketIDontKnowWhatItIs() {
|
fun sendSomePacketIDontKnowWhatItIs() {
|
||||||
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
// 2900 00ff ffff ffff ffff -- enables setting EQ
|
||||||
sendDataPacket(
|
sendDataPacket(
|
||||||
+24
-9
@@ -21,17 +21,18 @@
|
|||||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
@@ -49,7 +50,7 @@ enum class ATTCCCDHandles(val value: Int) {
|
|||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val device: BluetoothDevice) {
|
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ATTManager"
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
@@ -62,18 +63,26 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
private var input: InputStream? = null
|
private var input: InputStream? = null
|
||||||
private var output: OutputStream? = null
|
private var output: OutputStream? = null
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
||||||
private var notificationJob: kotlinx.coroutines.Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
// queue for non-notification PDUs (responses to requests)
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
private val responses = LinkedBlockingQueue<ByteArray>()
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun connect() {
|
fun connect() {
|
||||||
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
|
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
socket = createBluetoothSocket(device, uuid)
|
try {
|
||||||
socket!!.connect()
|
socket = createBluetoothSocket(adapter, device, uuid)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to create socket")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket!!.connect()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ATT socket failed to connect")
|
||||||
|
return
|
||||||
|
}
|
||||||
input = socket!!.inputStream
|
input = socket!!.inputStream
|
||||||
output = socket!!.outputStream
|
output = socket!!.outputStream
|
||||||
Log.d(TAG, "Connected to ATT")
|
Log.d(TAG, "Connected to ATT")
|
||||||
@@ -195,9 +204,10 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
||||||
val type = 3 // L2CAP
|
val type = 3 // L2CAP
|
||||||
val constructorSpecs = listOf(
|
val constructorSpecs = listOf(
|
||||||
|
arrayOf(adapter, device, type, true, true, 31, uuid),
|
||||||
arrayOf(device, type, true, true, 31, uuid),
|
arrayOf(device, type, true, true, 31, uuid),
|
||||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
arrayOf(device, type, 1, true, true, 31, uuid),
|
||||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||||
@@ -219,7 +229,12 @@ class ATTManager(private val device: BluetoothDevice) {
|
|||||||
try {
|
try {
|
||||||
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
|
||||||
attemptedConstructors++
|
attemptedConstructors++
|
||||||
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
|
|
||||||
|
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
|
||||||
|
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
|
||||||
|
constructor.isAccessible = true
|
||||||
|
return constructor.newInstance(*params) as BluetoothSocket
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
||||||
lastException = e
|
lastException = e
|
||||||
+3
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.BluetoothCryptography
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.collections.iterator
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicator(
|
|
||||||
batteryPercentage: Int,
|
|
||||||
charging: Boolean = false,
|
|
||||||
prefix: String = "",
|
|
||||||
previousCharging: Boolean = false,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val batteryFillColor = if (batteryPercentage > 25)
|
|
||||||
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
|
||||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
|
||||||
|
|
||||||
val initialScale = if (previousCharging) 1f else 0f
|
|
||||||
val scaleAnim = remember { Animatable(initialScale) }
|
|
||||||
val targetScale = if (charging) 1f else 0f
|
|
||||||
|
|
||||||
LaunchedEffect(previousCharging, charging) {
|
|
||||||
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(backgroundColor), // just for haze to work
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = { batteryPercentage / 100f },
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
color = batteryFillColor,
|
|
||||||
gapSize = 0.dp,
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
strokeWidth = 4.dp,
|
|
||||||
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "\uDBC0\uDEE6",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = batteryFillColor,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
modifier = Modifier.scale(scaleAnim.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$prefix $batteryPercentage%",
|
|
||||||
color = batteryTextColor,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryIndicatorPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.imageResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
|
||||||
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION") val batteryReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
|
|
||||||
batteryStatus.value =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
|
||||||
} else {
|
|
||||||
intent.getParcelableArrayListExtra("data")
|
|
||||||
}?.toList() ?: listOf()
|
|
||||||
}
|
|
||||||
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
}
|
|
||||||
catch (_: IllegalArgumentException) {
|
|
||||||
Log.wtf("BatteryReceiver", "Receiver already unregistered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(context) {
|
|
||||||
val batteryIntentFilter = IntentFilter()
|
|
||||||
.apply {
|
|
||||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(
|
|
||||||
batteryReceiver,
|
|
||||||
batteryIntentFilter,
|
|
||||||
Context.RECEIVER_EXPORTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
batteryStatus.value = service.getBattery()
|
|
||||||
|
|
||||||
if (preview) {
|
|
||||||
batteryStatus.value = listOf(
|
|
||||||
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
|
|
||||||
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
|
|
||||||
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
|
|
||||||
)
|
|
||||||
previousBatteryStatus.value = batteryStatus.value
|
|
||||||
}
|
|
||||||
|
|
||||||
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val leftLevel = left?.level ?: 0
|
|
||||||
val rightLevel = right?.level ?: 0
|
|
||||||
val caseLevel = case?.level ?: 0
|
|
||||||
val leftCharging = left?.status == BatteryStatus.CHARGING
|
|
||||||
val rightCharging = right?.status == BatteryStatus.CHARGING
|
|
||||||
val caseCharging = case?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
|
|
||||||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
|
|
||||||
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
|
|
||||||
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
|
|
||||||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
|
|
||||||
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
|
|
||||||
|
|
||||||
val singleDisplayed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val budsRes = airpodsInstance.model.budsRes
|
|
||||||
val caseRes = airpodsInstance.model.caseRes
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.5f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image (
|
|
||||||
bitmap = ImageBitmap.imageResource(budsRes),
|
|
||||||
contentDescription = stringResource(R.string.buds),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
leftCharging == rightCharging &&
|
|
||||||
(leftLevel - rightLevel) in -3..3
|
|
||||||
)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel.coerceAtMost(rightLevel),
|
|
||||||
leftCharging,
|
|
||||||
previousCharging = (prevLeftCharging && prevRightCharging)
|
|
||||||
)
|
|
||||||
singleDisplayed.value = true
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
singleDisplayed.value = false
|
|
||||||
Row (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
leftLevel,
|
|
||||||
leftCharging,
|
|
||||||
"\uDBC6\uDCE5",
|
|
||||||
previousCharging = prevLeftCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (leftLevel > 0 && rightLevel > 0)
|
|
||||||
{
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
|
|
||||||
{
|
|
||||||
BatteryIndicator(
|
|
||||||
rightLevel,
|
|
||||||
rightCharging,
|
|
||||||
"\uDBC6\uDCE8",
|
|
||||||
previousCharging = prevRightCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
bitmap = ImageBitmap.imageResource(caseRes),
|
|
||||||
contentDescription = stringResource(R.string.case_alt),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(8.dp)
|
|
||||||
)
|
|
||||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
|
||||||
BatteryIndicator(
|
|
||||||
caseLevel,
|
|
||||||
caseCharging,
|
|
||||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
|
|
||||||
previousCharging = prevCaseCharging
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun BatteryViewPreview() {
|
|
||||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(bg)
|
|
||||||
) {
|
|
||||||
BatteryView(AirPodsService(), preview = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.requiredWidthIn
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
|
||||||
fun ConfirmationDialog(
|
|
||||||
showDialog: MutableState<Boolean>,
|
|
||||||
title: String,
|
|
||||||
message: String,
|
|
||||||
confirmText: String = "Enable",
|
|
||||||
dismissText: String = "Cancel",
|
|
||||||
onConfirm: () -> Unit,
|
|
||||||
onDismiss: () -> Unit = { showDialog.value = false },
|
|
||||||
hazeState: HazeState,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
if (showDialog.value) {
|
|
||||||
Dialog(onDismissRequest = { showDialog.value = false }) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
// .fillMaxWidth(0.75f)
|
|
||||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
|
||||||
.background(Color.Transparent, RoundedCornerShape(14.dp))
|
|
||||||
.clip(RoundedCornerShape(14.dp))
|
|
||||||
.hazeEffect(
|
|
||||||
hazeState,
|
|
||||||
style = CupertinoMaterials.regular(
|
|
||||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(alpha = 0.8f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
var leftPressed by remember { mutableStateOf(false) }
|
|
||||||
var rightPressed by remember { mutableStateOf(false) }
|
|
||||||
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val position = event.changes.first().position
|
|
||||||
val width = size.width.toFloat()
|
|
||||||
val height = size.height.toFloat()
|
|
||||||
val isWithinBounds = position.y >= 0 && position.y <= height
|
|
||||||
val isLeft = position.x < width / 2
|
|
||||||
event.changes.first().consume()
|
|
||||||
when (event.type) {
|
|
||||||
PointerEventType.Press -> {
|
|
||||||
if (isWithinBounds) {
|
|
||||||
leftPressed = isLeft
|
|
||||||
rightPressed = !isLeft
|
|
||||||
} else {
|
|
||||||
leftPressed = false
|
|
||||||
rightPressed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PointerEventType.Move -> {
|
|
||||||
if (isWithinBounds) {
|
|
||||||
leftPressed = isLeft
|
|
||||||
rightPressed = !isLeft
|
|
||||||
} else {
|
|
||||||
leftPressed = false
|
|
||||||
rightPressed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PointerEventType.Release -> {
|
|
||||||
if (isWithinBounds) {
|
|
||||||
if (leftPressed) {
|
|
||||||
onDismiss()
|
|
||||||
} else if (rightPressed) {
|
|
||||||
onConfirm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
leftPressed = false
|
|
||||||
rightPressed = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.Start,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(if (leftPressed) pressedColor else Color.Transparent),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = dismissText,
|
|
||||||
style = TextStyle(
|
|
||||||
color = accentColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(1.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(Color(0x40888888))
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(if (rightPressed) pressedColor else Color.Transparent),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = confirmText,
|
|
||||||
style = TextStyle(
|
|
||||||
color = accentColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HearingHealthSettings(navController: NavController) {
|
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hearing_health),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_protection",
|
|
||||||
name = stringResource(R.string.hearing_protection),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController,
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid",
|
|
||||||
name = stringResource(R.string.hearing_aid),
|
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,687 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
var checked by checkedState
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val service = ServiceManager.getService() ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == controlCommandIdentifier
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
fun cb() {
|
|
||||||
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == controlCommandIdentifier.value) {
|
|
||||||
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
|
|
||||||
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StyledToggle(
|
|
||||||
title: String? = null,
|
|
||||||
label: String,
|
|
||||||
description: String? = null,
|
|
||||||
attHandle: ATTHandles,
|
|
||||||
independent: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
sharedPreferenceKey: String? = null,
|
|
||||||
sharedPreferences: SharedPreferences? = null,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val checkedValue = try {
|
|
||||||
attManager.read(attHandle).getOrNull(0)?.toInt()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
|
|
||||||
null
|
|
||||||
} ?: 0
|
|
||||||
var checked by remember { mutableStateOf(checkedValue !=0) }
|
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
|
||||||
|
|
||||||
attManager.enableNotifications(attHandle)
|
|
||||||
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cb() {
|
|
||||||
if (sharedPreferences != null) {
|
|
||||||
if (sharedPreferenceKey == null) {
|
|
||||||
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
|
|
||||||
}
|
|
||||||
onCheckedChange?.invoke(checked)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(checked) {
|
|
||||||
if (attManager.socket?.isConnected != true) return@LaunchedEffect
|
|
||||||
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
if (value.isNotEmpty()) {
|
|
||||||
checked = value[0].toInt() != 0
|
|
||||||
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
|
|
||||||
} else {
|
|
||||||
Log.w("StyledToggle", "Empty value in notification for $label")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
attManager.registerListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(attHandle, listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (independent) {
|
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(4.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
},
|
|
||||||
onTap = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
checked = !checked
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
checked = it
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun StyledTogglePreview() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sharedPrefs = context.getSharedPreferences("preview", 0)
|
|
||||||
StyledToggle(
|
|
||||||
label = "Example Toggle",
|
|
||||||
description = "This is an example description for the styled toggle.",
|
|
||||||
sharedPreferences = sharedPrefs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+83
-41
@@ -16,10 +16,8 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTManager
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
open class AirPodsBase(
|
open class AirPodsBase(
|
||||||
@@ -52,33 +50,48 @@ enum class Capability {
|
|||||||
class AirPods: AirPodsBase(
|
class AirPods: AirPodsBase(
|
||||||
modelNumber = listOf("A1523", "A1722"),
|
modelNumber = listOf("A1523", "A1722"),
|
||||||
name = "AirPods 1",
|
name = "AirPods 1",
|
||||||
budCaseRes = R.drawable.airpods_1,
|
// budCaseRes = R.drawable.airpods_1
|
||||||
budsRes = R.drawable.airpods_1_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_1_left,
|
// budsRes = R.drawable.airpods_1_buds
|
||||||
rightBudsRes = R.drawable.airpods_1_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_1_case,
|
// leftBudsRes = R.drawable.airpods_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = emptySet()
|
capabilities = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPods2: AirPodsBase(
|
class AirPods2: AirPodsBase(
|
||||||
modelNumber = listOf("A2032", "A2031"),
|
modelNumber = listOf("A2032", "A2031"),
|
||||||
name = "AirPods 2",
|
name = "AirPods 2",
|
||||||
budCaseRes = R.drawable.airpods_2,
|
// budCaseRes = R.drawable.airpods_2
|
||||||
budsRes = R.drawable.airpods_2_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_2_left,
|
// budsRes = R.drawable.airpods_2_buds
|
||||||
rightBudsRes = R.drawable.airpods_2_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_2_case,
|
// leftBudsRes = R.drawable.airpods_2_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_2_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_2_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = emptySet()
|
capabilities = emptySet()
|
||||||
)
|
)
|
||||||
|
|
||||||
class AirPods3: AirPodsBase(
|
class AirPods3: AirPodsBase(
|
||||||
modelNumber = listOf("A2565", "A2564"),
|
modelNumber = listOf("A2565", "A2564"),
|
||||||
name = "AirPods 3",
|
name = "AirPods 3",
|
||||||
budCaseRes = R.drawable.airpods_3,
|
// budCaseRes = R.drawable.airpods_3
|
||||||
budsRes = R.drawable.airpods_3_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_3_left,
|
// budsRes = R.drawable.airpods_3_buds
|
||||||
rightBudsRes = R.drawable.airpods_3_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_3_case,
|
// leftBudsRes = R.drawable.airpods_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.HEAD_GESTURES
|
Capability.HEAD_GESTURES
|
||||||
)
|
)
|
||||||
@@ -87,11 +100,16 @@ class AirPods3: AirPodsBase(
|
|||||||
class AirPods4: AirPodsBase(
|
class AirPods4: AirPodsBase(
|
||||||
modelNumber = listOf("A3053", "A3050", "A3054"),
|
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||||
name = "AirPods 4",
|
name = "AirPods 4",
|
||||||
budCaseRes = R.drawable.airpods_4,
|
// budCaseRes = R.drawable.airpods_4
|
||||||
budsRes = R.drawable.airpods_4_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_4_left,
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
rightBudsRes = R.drawable.airpods_4_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_4_case,
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.HEAD_GESTURES,
|
Capability.HEAD_GESTURES,
|
||||||
Capability.SLEEP_DETECTION,
|
Capability.SLEEP_DETECTION,
|
||||||
@@ -102,18 +120,24 @@ class AirPods4: AirPodsBase(
|
|||||||
class AirPods4ANC: AirPodsBase(
|
class AirPods4ANC: AirPodsBase(
|
||||||
modelNumber = listOf("A3056", "A3055", "A3057"),
|
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||||
name = "AirPods 4 (ANC)",
|
name = "AirPods 4 (ANC)",
|
||||||
budCaseRes = R.drawable.airpods_4,
|
// budCaseRes = R.drawable.airpods_4
|
||||||
budsRes = R.drawable.airpods_4_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_4_left,
|
// budsRes = R.drawable.airpods_4_buds
|
||||||
rightBudsRes = R.drawable.airpods_4_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_4_case,
|
// leftBudsRes = R.drawable.airpods_4_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_4_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_4_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
Capability.CONVERSATION_AWARENESS,
|
Capability.CONVERSATION_AWARENESS,
|
||||||
Capability.HEAD_GESTURES,
|
Capability.HEAD_GESTURES,
|
||||||
Capability.ADAPTIVE_AUDIO,
|
Capability.ADAPTIVE_AUDIO,
|
||||||
Capability.SLEEP_DETECTION,
|
Capability.SLEEP_DETECTION,
|
||||||
Capability.ADAPTIVE_VOLUME
|
Capability.ADAPTIVE_VOLUME,
|
||||||
|
Capability.STEM_CONFIG
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,11 +145,16 @@ class AirPodsPro1: AirPodsBase(
|
|||||||
modelNumber = listOf("A2084", "A2083"),
|
modelNumber = listOf("A2084", "A2083"),
|
||||||
name = "AirPods Pro 1",
|
name = "AirPods Pro 1",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
budCaseRes = R.drawable.airpods_pro_1,
|
// budCaseRes = R.drawable.airpods_pro_1
|
||||||
budsRes = R.drawable.airpods_pro_1_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_pro_1_left,
|
// budsRes = R.drawable.airpods_pro_1_buds
|
||||||
rightBudsRes = R.drawable.airpods_pro_1_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_pro_1_case,
|
// leftBudsRes = R.drawable.airpods_pro_1_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_1_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_1_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE
|
Capability.LISTENING_MODE
|
||||||
)
|
)
|
||||||
@@ -135,10 +164,15 @@ class AirPodsPro2Lightning: AirPodsBase(
|
|||||||
modelNumber = listOf("A2931", "A2699", "A2698"),
|
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||||
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
|
// budCaseRes = R.drawable.airpods_pro_2
|
||||||
budCaseRes = R.drawable.airpods_pro_2,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
budsRes = R.drawable.airpods_pro_2_buds,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
caseRes = R.drawable.airpods_pro_2_case,
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
@@ -158,10 +192,15 @@ class AirPodsPro2USBC: AirPodsBase(
|
|||||||
modelNumber = listOf("A3047", "A3048", "A3049"),
|
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||||
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
|
// budCaseRes = R.drawable.airpods_pro_2
|
||||||
budCaseRes = R.drawable.airpods_pro_2,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
|
// budsRes = R.drawable.airpods_pro_2_buds
|
||||||
budsRes = R.drawable.airpods_pro_2_buds,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_2_case
|
||||||
caseRes = R.drawable.airpods_pro_2_case,
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
@@ -181,11 +220,16 @@ class AirPodsPro3: AirPodsBase(
|
|||||||
modelNumber = listOf("A3063", "A3064", "A3065"),
|
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||||
name = "AirPods Pro 3",
|
name = "AirPods Pro 3",
|
||||||
displayName = "AirPods Pro",
|
displayName = "AirPods Pro",
|
||||||
budCaseRes = R.drawable.airpods_pro_3,
|
// budCaseRes = R.drawable.airpods_pro_3
|
||||||
budsRes = R.drawable.airpods_pro_3_buds,
|
budCaseRes = R.drawable.airpods_pro_2,
|
||||||
leftBudsRes = R.drawable.airpods_pro_3_left,
|
// budsRes = R.drawable.airpods_pro_3_buds
|
||||||
rightBudsRes = R.drawable.airpods_pro_3_right,
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
caseRes = R.drawable.airpods_pro_3_case,
|
// leftBudsRes = R.drawable.airpods_pro_3_left
|
||||||
|
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||||
|
// rightBudsRes = R.drawable.airpods_pro_3_right
|
||||||
|
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||||
|
// caseRes = R.drawable.airpods_pro_3_case
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case,
|
||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
Capability.CONVERSATION_AWARENESS,
|
Capability.CONVERSATION_AWARENESS,
|
||||||
@@ -212,8 +256,6 @@ data class AirPodsInstance(
|
|||||||
val version1: String?,
|
val version1: String?,
|
||||||
val version2: String?,
|
val version2: String?,
|
||||||
val version3: String?,
|
val version3: String?,
|
||||||
val aacpManager: AACPManager,
|
|
||||||
val attManager: ATTManager?
|
|
||||||
)
|
)
|
||||||
|
|
||||||
object AirPodsModels {
|
object AirPodsModels {
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
|
||||||
|
class ControlCommandRepository(
|
||||||
|
private val aacpManager: AACPManager
|
||||||
|
) {
|
||||||
|
fun getValue(
|
||||||
|
identifier: ControlCommandIdentifiers
|
||||||
|
): ByteArray? {
|
||||||
|
return aacpManager.controlCommandStatusList
|
||||||
|
.find { it.identifier == identifier }
|
||||||
|
?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setValue(
|
||||||
|
id: ControlCommandIdentifiers,
|
||||||
|
value: ByteArray
|
||||||
|
) {
|
||||||
|
aacpManager.sendControlCommand(id.value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun observe(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
onChange: (ByteArray) -> Unit
|
||||||
|
): AACPManager.ControlCommandListener {
|
||||||
|
|
||||||
|
val listener = object : AACPManager.ControlCommandListener {
|
||||||
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
|
onChange(controlCommand.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aacpManager.registerControlCommandListener(identifier, listener)
|
||||||
|
return listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(
|
||||||
|
identifier: ControlCommandIdentifiers,
|
||||||
|
listener: AACPManager.ControlCommandListener
|
||||||
|
) {
|
||||||
|
aacpManager.unregisterControlCommandListener(identifier, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
|
||||||
|
return aacpManager.controlCommandStatusList.associate {
|
||||||
|
it.identifier to it.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
@@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
+6
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -40,6 +40,7 @@ object BatteryStatus {
|
|||||||
const val CHARGING = 1
|
const val CHARGING = 1
|
||||||
const val NOT_CHARGING = 2
|
const val NOT_CHARGING = 2
|
||||||
const val DISCONNECTED = 4
|
const val DISCONNECTED = 4
|
||||||
|
const val OPTIMIZED_CHARGING = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -58,6 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
|
|||||||
BatteryStatus.CHARGING -> "CHARGING"
|
BatteryStatus.CHARGING -> "CHARGING"
|
||||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||||
|
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ enum class NoiseControlMode {
|
|||||||
class AirPodsNotifications {
|
class AirPodsNotifications {
|
||||||
companion object {
|
companion object {
|
||||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||||
|
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||||
@@ -78,6 +81,8 @@ class AirPodsNotifications {
|
|||||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||||
|
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
||||||
|
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||||
}
|
}
|
||||||
|
|
||||||
class EarDetection {
|
class EarDetection {
|
||||||
+2
-2
@@ -16,9 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.constants
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
|
||||||
enum class StemAction {
|
enum class StemAction {
|
||||||
PLAY_PAUSE,
|
PLAY_PAUSE,
|
||||||
+4
-3
@@ -16,13 +16,14 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -139,7 +140,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
|||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
|
|
||||||
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) {
|
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||||
debounceJob?.cancel()
|
debounceJob?.cancel()
|
||||||
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
@@ -171,7 +172,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
|
|||||||
}
|
}
|
||||||
|
|
||||||
val data = buffer.array()
|
val data = buffer.array()
|
||||||
attManager.write(ATTHandles.TRANSPARENCY, value = data)
|
writer(ATTHandles.TRANSPARENCY, data)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
interface XposedRemotePref {
|
||||||
|
fun isAvailable(): Boolean
|
||||||
|
|
||||||
|
fun getBoolean(key: String, def: Boolean): Boolean
|
||||||
|
fun putBoolean(key: String, value: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
object XposedRemotePrefProvider {
|
||||||
|
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||||
|
}
|
||||||
+22
-21
@@ -18,12 +18,12 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutCard(navController: NavController) {
|
fun AboutCard(
|
||||||
|
navController: NavController,
|
||||||
|
modelName: String,
|
||||||
|
actualModel: String,
|
||||||
|
serialNumbers: List<String>,
|
||||||
|
version: String?
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -75,7 +75,8 @@ fun AboutCard(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.model.displayName,
|
text = modelName,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -137,7 +138,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.actualModelNumber,
|
text = actualModel,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -152,11 +153,11 @@ fun AboutCard(navController: NavController) {
|
|||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
)
|
)
|
||||||
val serialNumbers = listOf(
|
val serialNumbers = listOf(
|
||||||
airpodsInstance.serialNumber?: "",
|
serialNumbers[0],
|
||||||
" ${airpodsInstance.leftSerialNumber}",
|
" ${serialNumbers[1]}",
|
||||||
" ${airpodsInstance.rightSerialNumber}"
|
" ${serialNumbers[2]}"
|
||||||
)
|
)
|
||||||
val serialNumber = remember { mutableStateOf(0) }
|
val serialNumber = remember { mutableIntStateOf(0) }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -172,7 +173,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = serialNumbers[serialNumber.value],
|
text = serialNumbers[serialNumber.intValue],
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
|
||||||
@@ -183,7 +184,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
|
serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,7 @@ fun AboutCard(navController: NavController) {
|
|||||||
to = "version_info",
|
to = "version_info",
|
||||||
navController = navController,
|
navController = navController,
|
||||||
name = stringResource(R.string.version),
|
name = stringResource(R.string.version),
|
||||||
currentState = airpodsInstance.version3,
|
currentState = version,
|
||||||
independent = false,
|
independent = false,
|
||||||
height = rowHeight.value + 32.dp
|
height = rowHeight.value + 32.dp
|
||||||
)
|
)
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppInfoCard() {
|
||||||
|
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
|
.padding(start = 16.dp, bottom = 8.dp, end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.about), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.VERSION_NAME, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version_code), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.flavor), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.FLAVOR, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.build_type), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = BuildConfig.BUILD_TYPE,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-27
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -42,25 +44,32 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettings(navController: NavController) {
|
fun AudioSettings(
|
||||||
|
navController: NavController,
|
||||||
|
adaptiveVolumeCapability: Boolean,
|
||||||
|
conversationalAwarenessCapability: Boolean,
|
||||||
|
loudSoundReductionCapability: Boolean,
|
||||||
|
adaptiveAudioCapability: Boolean,
|
||||||
|
|
||||||
|
adaptiveVolumeChecked: Boolean,
|
||||||
|
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
conversationalAwarenessChecked: Boolean,
|
||||||
|
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
loudSoundReductionChecked: Boolean,
|
||||||
|
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||||
|
|
||||||
|
vendorIdHook: Boolean,
|
||||||
|
isPremium: Boolean
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
|
|
||||||
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -73,7 +82,8 @@ fun AudioSettings(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -88,12 +98,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
|
if (adaptiveVolumeCapability) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.personalized_volume),
|
label = stringResource(R.string.personalized_volume),
|
||||||
description = stringResource(R.string.personalized_volume_description),
|
description = stringResource(R.string.personalized_volume_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = adaptiveVolumeChecked,
|
||||||
|
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -104,12 +116,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
|
if (conversationalAwarenessCapability) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversational_awareness),
|
label = stringResource(R.string.conversational_awareness),
|
||||||
description = stringResource(R.string.conversational_awareness_description),
|
description = stringResource(R.string.conversational_awareness_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
independent = false,
|
||||||
independent = false
|
checked = conversationalAwarenessChecked,
|
||||||
|
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -119,12 +133,14 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
if (loudSoundReductionCapability && vendorIdHook){
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
|
independent = false,
|
||||||
independent = false
|
checked = loudSoundReductionChecked,
|
||||||
|
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||||
|
enabled = isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -134,7 +150,7 @@ fun AudioSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
|
if (adaptiveAudioCapability) {
|
||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "adaptive_strength",
|
to = "adaptive_strength",
|
||||||
name = stringResource(R.string.adaptive_audio),
|
name = stringResource(R.string.adaptive_audio),
|
||||||
@@ -148,5 +164,19 @@ fun AudioSettings(navController: NavController) {
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(rememberNavController())
|
AudioSettings(
|
||||||
|
navController = rememberNavController(),
|
||||||
|
adaptiveVolumeCapability = true,
|
||||||
|
conversationalAwarenessCapability = true,
|
||||||
|
loudSoundReductionCapability = true,
|
||||||
|
adaptiveAudioCapability = true,
|
||||||
|
adaptiveVolumeChecked = true,
|
||||||
|
onAdaptiveVolumeCheckedChange = { },
|
||||||
|
conversationalAwarenessChecked = true,
|
||||||
|
onConversationalAwarenessCheckedChange = { },
|
||||||
|
loudSoundReductionChecked = true,
|
||||||
|
onLoudSoundReductionCheckedChange = { },
|
||||||
|
vendorIdHook = true,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicator(
|
||||||
|
batteryPercentage: Int,
|
||||||
|
status: Int,
|
||||||
|
prefix: String = "",
|
||||||
|
previousCharging: Boolean = false,
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val batteryFillColor =
|
||||||
|
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||||
|
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||||
|
|
||||||
|
val initialScale = if (previousCharging) 1f else 0f
|
||||||
|
val scaleAnim = remember { Animatable(initialScale) }
|
||||||
|
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
val targetScale = if (charging) 1f else 0f
|
||||||
|
|
||||||
|
LaunchedEffect(previousCharging, charging) {
|
||||||
|
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
|
||||||
|
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
|
||||||
|
|
||||||
|
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
|
||||||
|
val optimizedLimit = 0.8f
|
||||||
|
val progress = batteryPercentage / 100f
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.size(34.dp)) {
|
||||||
|
val startAngle = -90f
|
||||||
|
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
|
||||||
|
val inset = strokeWidthPx / 2
|
||||||
|
Rect(
|
||||||
|
left = inset,
|
||||||
|
top = inset,
|
||||||
|
right = size.width - inset,
|
||||||
|
bottom = size.height - inset
|
||||||
|
)
|
||||||
|
val radius = size.minDimension / 2
|
||||||
|
|
||||||
|
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * optimizedLimit,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
val sweep = 360f * min(progress, optimizedLimit)
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = sweep,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- PILL MARKER AT 80% ----
|
||||||
|
val angleDeg = startAngle + 360f * optimizedLimit
|
||||||
|
val angleRad = Math.toRadians(angleDeg.toDouble())
|
||||||
|
|
||||||
|
val arcRadius = radius - strokeWidthPx
|
||||||
|
|
||||||
|
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
|
||||||
|
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
|
||||||
|
|
||||||
|
val dirX = center.x - outerX
|
||||||
|
val dirY = center.y - outerY
|
||||||
|
val length = sqrt(dirX * dirX + dirY * dirY)
|
||||||
|
|
||||||
|
val normX = dirX / length
|
||||||
|
val normY = dirY / length
|
||||||
|
|
||||||
|
val startX = outerX - normX * strokeWidthPx / 2
|
||||||
|
val startY = outerY - normY * strokeWidthPx / 2
|
||||||
|
|
||||||
|
val endX = center.x - normX * gapFromCenterPx
|
||||||
|
val endY = center.y - normY * gapFromCenterPx
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
|
||||||
|
start = Offset(startX, startY),
|
||||||
|
end = Offset(endX, endY),
|
||||||
|
strokeWidth = strokeWidthPx,
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = 0f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = batteryFillColor,
|
||||||
|
startAngle = startAngle,
|
||||||
|
sweepAngle = 360f * progress,
|
||||||
|
useCenter = false,
|
||||||
|
style = stroke
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "\uDBC0\uDEE6", style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = batteryFillColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
), modifier = Modifier.scale(scaleAnim.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "$prefix $batteryPercentage%",
|
||||||
|
color = batteryTextColor,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryIndicatorPreview() {
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(bg)
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
batteryPercentage = 50,
|
||||||
|
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||||
|
prefix = "\uDBC6\uDCE5",
|
||||||
|
previousCharging = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.res.imageResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.Battery
|
||||||
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryView(
|
||||||
|
batteryList: List<Battery>,
|
||||||
|
budsRes: Int,
|
||||||
|
caseRes: Int
|
||||||
|
) {
|
||||||
|
val left = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
val case = batteryList.find { it.component == BatteryComponent.CASE }
|
||||||
|
|
||||||
|
val leftLevel = left?.level ?: 0
|
||||||
|
val rightLevel = right?.level ?: 0
|
||||||
|
val caseLevel = case?.level ?: 0
|
||||||
|
|
||||||
|
val caseCharging = case?.status == BatteryStatus.CHARGING ||
|
||||||
|
case?.status == BatteryStatus.OPTIMIZED_CHARGING
|
||||||
|
|
||||||
|
val singleDisplayed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.widthIn(max = 500.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(budsRes),
|
||||||
|
contentDescription = stringResource(R.string.buds),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
left?.status == right?.status &&
|
||||||
|
(leftLevel - rightLevel) in -3..3
|
||||||
|
) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel.coerceAtMost(rightLevel),
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING
|
||||||
|
)
|
||||||
|
singleDisplayed.value = true
|
||||||
|
} else {
|
||||||
|
singleDisplayed.value = false
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
leftLevel,
|
||||||
|
left?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE5"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftLevel > 0 && rightLevel > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
rightLevel,
|
||||||
|
right?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
"\uDBC6\uDCE8"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = ImageBitmap.imageResource(caseRes),
|
||||||
|
contentDescription = stringResource(R.string.case_alt),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||||
|
BatteryIndicator(
|
||||||
|
caseLevel,
|
||||||
|
case?.status ?: BatteryStatus.NOT_CHARGING,
|
||||||
|
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||||
|
@Composable
|
||||||
|
fun BatteryViewPreview() {
|
||||||
|
val fakeBattery = listOf(
|
||||||
|
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||||
|
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
|
||||||
|
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
|
||||||
|
)
|
||||||
|
|
||||||
|
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(bg)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = fakeBattery,
|
||||||
|
budsRes = R.drawable.airpods_pro_2_buds,
|
||||||
|
caseRes = R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-77
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -36,39 +36,43 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun CallControlSettings(hazeState: HazeState) {
|
fun CallControlSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
flipped: Boolean,
|
||||||
|
onCallControlValueChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -82,7 +86,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,24 +98,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
|
val scope = rememberCoroutineScope()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
val haptics = LocalHapticFeedback.current
|
||||||
}?.value ?: byteArrayOf(0x00, 0x03)
|
|
||||||
|
|
||||||
val pressOnceText = stringResource(R.string.press_once)
|
val pressOnceText = stringResource(R.string.press_once)
|
||||||
val pressTwiceText = stringResource(R.string.press_twice)
|
val pressTwiceText = stringResource(R.string.press_twice)
|
||||||
|
|
||||||
var flipped by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
callControlEnabledValue.contentEquals(
|
|
||||||
byteArrayOf(
|
|
||||||
0x00,
|
|
||||||
0x02
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||||
|
|
||||||
@@ -120,6 +114,7 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
var parentDragActiveSingle by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
||||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
||||||
@@ -127,36 +122,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
||||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
var parentDragActiveDouble by remember { mutableStateOf(false) }
|
||||||
|
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
|
|
||||||
) {
|
|
||||||
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
|
|
||||||
flipped = newFlipped
|
|
||||||
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
|
|
||||||
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
|
|
||||||
Log.d(
|
|
||||||
"CallControlSettings",
|
|
||||||
"Control command received, flipped: $newFlipped"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(flipped) {
|
LaunchedEffect(flipped) {
|
||||||
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
Log.d("CallControlSettings", "Call control flipped: $flipped")
|
||||||
}
|
}
|
||||||
@@ -231,7 +198,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetSingle ?: current
|
val touch = touchOffsetSingle ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxSingle) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = idx
|
parentHoveredIndexSingle = idx
|
||||||
|
previousIdxSingle = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveSingle = false
|
parentDragActiveSingle = false
|
||||||
@@ -244,13 +215,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
lastDismissTimeSingle = System.currentTimeMillis()
|
lastDismissTimeSingle = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
onCallControlValueChanged(option != pressOnceText)
|
||||||
0x00,
|
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexSingle = null
|
parentHoveredIndexSingle = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -313,11 +284,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
doublePressAction =
|
doublePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showSinglePressDropdown = false
|
showSinglePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option != pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x03
|
|
||||||
) else byteArrayOf(0x00, 0x02)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -366,7 +334,11 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffsetDouble ?: current
|
val touch = touchOffsetDouble ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdxDouble) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = idx
|
parentHoveredIndexDouble = idx
|
||||||
|
previousIdxDouble = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActiveDouble = false
|
parentDragActiveDouble = false
|
||||||
@@ -379,13 +351,13 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
lastDismissTimeDouble = System.currentTimeMillis()
|
lastDismissTimeDouble = System.currentTimeMillis()
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndexDouble = null
|
parentHoveredIndexDouble = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -448,11 +420,8 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
singlePressAction =
|
singlePressAction =
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
if (option == pressOnceText) pressTwiceText else pressOnceText
|
||||||
showDoublePressDropdown = false
|
showDoublePressDropdown = false
|
||||||
val bytes = if (option == pressOnceText) byteArrayOf(
|
val flipped = option == pressOnceText
|
||||||
0x00,
|
onCallControlValueChanged(flipped)
|
||||||
0x02
|
|
||||||
) else byteArrayOf(0x00, 0x03)
|
|
||||||
service.aacpManager.sendControlCommand(0x24, bytes)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -461,10 +430,3 @@ fun CallControlSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun CallControlSettingsPreview() {
|
|
||||||
CallControlSettings(HazeState())
|
|
||||||
}
|
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredWidthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.effects.blur
|
||||||
|
import com.kyant.backdrop.effects.lens
|
||||||
|
import com.kyant.backdrop.effects.vibrancy
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
@ExperimentalHazeMaterialsApi
|
||||||
|
@Composable
|
||||||
|
fun ConfirmationDialog(
|
||||||
|
showDialog: MutableState<Boolean>,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
confirmText: String = "Enable",
|
||||||
|
dismissText: String = "Cancel",
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit = { showDialog.value = false },
|
||||||
|
backdrop: LayerBackdrop,
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showDialog.value,
|
||||||
|
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
|
||||||
|
exit = scaleOut(targetScale = 1.05f) + fadeOut()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val innerBackdrop = rememberLayerBackdrop()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f))
|
||||||
|
.clickable(enabled = false, onClick = {}),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||||
|
.clip(RoundedCornerShape(48.dp))
|
||||||
|
.drawBackdrop(
|
||||||
|
backdrop = backdrop,
|
||||||
|
exportedBackdrop = innerBackdrop,
|
||||||
|
shape = { RoundedCornerShape(48.dp) },
|
||||||
|
effects = {
|
||||||
|
vibrancy()
|
||||||
|
blur(4f.dp.toPx())
|
||||||
|
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
drawRect(
|
||||||
|
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
})) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor.copy(alpha = 0.8f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.9f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dismissText, style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
StyledButton(
|
||||||
|
onClick = onConfirm,
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
surfaceColor = accentColor
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = confirmText, style = TextStyle(
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-20
@@ -18,9 +18,8 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,16 +30,18 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ConnectionSettings() {
|
fun ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled: Boolean,
|
||||||
|
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
|
||||||
|
automaticConnectionEnabled: Boolean,
|
||||||
|
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
@@ -52,10 +53,9 @@ fun ConnectionSettings() {
|
|||||||
) {
|
) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.ear_detection),
|
label = stringResource(R.string.ear_detection),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
independent = false,
|
||||||
sharedPreferenceKey = "automatic_ear_detection",
|
checked = automaticEarDetectionEnabled,
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
onCheckedChange = onAutomaticEarDetectionChanged
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 1.dp,
|
thickness = 1.dp,
|
||||||
@@ -67,16 +67,9 @@ fun ConnectionSettings() {
|
|||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.automatically_connect),
|
label = stringResource(R.string.automatically_connect),
|
||||||
description = stringResource(R.string.automatically_connect_description),
|
description = stringResource(R.string.automatically_connect_description),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
independent = false,
|
||||||
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
|
checked = automaticConnectionEnabled,
|
||||||
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
|
onCheckedChange = onAutomaticConnectionChanged
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ConnectionSettingsPreview() {
|
|
||||||
ConnectionSettings()
|
|
||||||
}
|
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:Suppress("unused")
|
@file:Suppress("unused")
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
+2
-2
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
|
|
||||||
private val ContainerColor = Color(0x593C3C3E)
|
private val ContainerColor = Color(0x593C3C3E)
|
||||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceInfoCard() {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
val rowHeight = remember { mutableStateOf(0.dp) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
Column (
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
|
.padding(start = 16.dp, top = 24.dp, end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_info), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.onGloballyPositioned { coordinates ->
|
||||||
|
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.manufacturer), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.MANUFACTURER, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.model_number), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.MODEL, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.build_id), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.DISPLAY, style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.version), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.xposed_available), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_enabled_in_xposed), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.8f
|
||||||
|
),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingHealthSettings(
|
||||||
|
navController: NavController,
|
||||||
|
hasPPECapability: Boolean,
|
||||||
|
hasHearingAidCapability: Boolean,
|
||||||
|
vendorIdHook: Boolean
|
||||||
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
|
||||||
|
|
||||||
|
if (hasPPECapability && shouldShowHearingAid) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
){
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_health),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(top = 2.dp)
|
||||||
|
) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController,
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (shouldShowHearingAid) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_aid",
|
||||||
|
name = stringResource(R.string.hearing_aid),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
} else if (hasPPECapability) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "hearing_protection",
|
||||||
|
name = stringResource(R.string.hearing_protection),
|
||||||
|
title = stringResource(R.string.hearing_health),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-60
@@ -18,9 +18,8 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -35,38 +34,41 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneSettings(hazeState: HazeState) {
|
fun MicrophoneSettings(
|
||||||
|
hazeState: HazeState,
|
||||||
|
micModeValue: Byte,
|
||||||
|
onMicModeValueChanged: (Byte) -> Unit
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
@@ -77,11 +79,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
val micModeValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
}?.value?.get(0) ?: 0x00.toByte()
|
|
||||||
|
|
||||||
var selectedMode by remember {
|
var selectedMode by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
when (micModeValue) {
|
when (micModeValue) {
|
||||||
@@ -98,42 +95,13 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
var lastDismissTime by remember { mutableLongStateOf(0L) }
|
||||||
val reopenThresholdMs = 250L
|
val reopenThresholdMs = 250L
|
||||||
|
|
||||||
val listener = object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
|
||||||
) {
|
|
||||||
selectedMode = when (controlCommand.value[0]) {
|
|
||||||
0x00.toByte() -> "Automatic"
|
|
||||||
0x01.toByte() -> "Always Right"
|
|
||||||
0x02.toByte() -> "Always Left"
|
|
||||||
else -> "Automatic"
|
|
||||||
}
|
|
||||||
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val itemHeightPx = with(density) { 48.dp.toPx() }
|
val itemHeightPx = with(density) { 48.dp.toPx() }
|
||||||
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var parentDragActive by remember { mutableStateOf(false) }
|
var parentDragActive by remember { mutableStateOf(false) }
|
||||||
|
var previousIdx by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
|
||||||
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
|
||||||
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
|
||||||
@@ -173,7 +141,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
val touch = touchOffset ?: current
|
val touch = touchOffset ?: current
|
||||||
val posInPopupY = current.y - touch.y
|
val posInPopupY = current.y - touch.y
|
||||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||||
|
if (idx != previousIdx) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
parentHoveredIndex = idx
|
parentHoveredIndex = idx
|
||||||
|
previousIdx = idx
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
parentDragActive = false
|
parentDragActive = false
|
||||||
@@ -194,12 +166,16 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
options[2] -> 0x02
|
options[2] -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
// service.aacpManager.sendControlCommand(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
||||||
byteArrayOf(byteValue.toByte())
|
// byteArrayOf(byteValue.toByte())
|
||||||
)
|
// )
|
||||||
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||||
|
}
|
||||||
parentHoveredIndex = null
|
parentHoveredIndex = null
|
||||||
},
|
},
|
||||||
onDragCancel = {
|
onDragCancel = {
|
||||||
@@ -277,10 +253,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
microphoneAlwaysLeftText -> 0x02
|
microphoneAlwaysLeftText -> 0x02
|
||||||
else -> 0x00
|
else -> 0x00
|
||||||
}
|
}
|
||||||
service.aacpManager.sendControlCommand(
|
onMicModeValueChanged(byteValue.toByte())
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
|
|
||||||
byteArrayOf(byteValue.toByte())
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
hazeState = hazeState
|
hazeState = hazeState
|
||||||
)
|
)
|
||||||
@@ -288,10 +261,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun MicrophoneSettingsPreview() {
|
|
||||||
MicrophoneSettings(HazeState())
|
|
||||||
}
|
|
||||||
+26
-7
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,10 +66,14 @@ fun NavigationButton(
|
|||||||
description: String? = null,
|
description: String? = null,
|
||||||
currentState: String? = null,
|
currentState: String? = null,
|
||||||
height: Dp = 58.dp,
|
height: Dp = 58.dp,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Box(
|
Box(
|
||||||
@@ -79,23 +87,34 @@ fun NavigationButton(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
|
.background(
|
||||||
|
animatedBackgroundColor,
|
||||||
|
RoundedCornerShape(if (independent) 28.dp else 0.dp)
|
||||||
|
)
|
||||||
.height(height)
|
.height(height)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (enabled) {
|
||||||
tryAwaitRelease()
|
backgroundColor =
|
||||||
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
if (onClick != null) onClick() else navController.navigate(to)
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
|
if (onClick != null) onClick() else navController.navigate(to)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
+35
-82
@@ -18,14 +18,9 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.SpringSpec
|
import androidx.compose.animation.core.SpringSpec
|
||||||
@@ -60,64 +55,49 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.imageResource
|
import androidx.compose.ui.res.imageResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
showOffListeningMode: Boolean,
|
||||||
|
noiseControlModeValue: Int,
|
||||||
|
onNoiseControlModeChanged: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
|
||||||
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
|
||||||
|
|
||||||
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
offListeningModeListener
|
|
||||||
)
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||||
|
|
||||||
val d1a = remember { mutableFloatStateOf(0f) }
|
val d1a = remember { mutableFloatStateOf(0f) }
|
||||||
val d2a = remember { mutableFloatStateOf(0f) }
|
val d2a = remember { mutableFloatStateOf(0f) }
|
||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
// this function exists solely for the dividers, should get rid of it
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
||||||
val previousMode = noiseControlMode.value
|
val previousMode = noiseControlMode.value
|
||||||
|
|
||||||
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
|
||||||
NoiseControlMode.TRANSPARENCY
|
NoiseControlMode.TRANSPARENCY
|
||||||
} else {
|
} else {
|
||||||
mode
|
mode
|
||||||
@@ -125,9 +105,8 @@ fun NoiseControlSettings(
|
|||||||
|
|
||||||
noiseControlMode.value = targetMode
|
noiseControlMode.value = targetMode
|
||||||
|
|
||||||
if (!received && targetMode != previousMode) {
|
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
|
||||||
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (noiseControlMode.value) {
|
when (noiseControlMode.value) {
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
@@ -153,32 +132,12 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val noiseControlReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == AirPodsNotifications.ANC_DATA) {
|
|
||||||
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
|
|
||||||
onModeSelected(noiseControlMode.value, true)
|
|
||||||
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val noiseControlIntentFilter = IntentFilter().apply {
|
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
|
||||||
addAction(AirPodsNotifications.ANC_DATA)
|
noiseControlMode.value = NoiseControlMode.entries[index]
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
onModeSelected(noiseControlMode.value, received = true)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||||
@@ -190,17 +149,18 @@ fun NoiseControlSettings(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val buttonCount = if (offListeningMode.value) 4 else 3
|
val buttonCount = if (showOffListeningMode) 4 else 3
|
||||||
val buttonWidth = maxWidth / buttonCount
|
val buttonWidth = maxWidth / buttonCount
|
||||||
|
|
||||||
val isDragging = remember { mutableStateOf(false) }
|
val isDragging = remember { mutableStateOf(false) }
|
||||||
@@ -208,10 +168,10 @@ fun NoiseControlSettings(
|
|||||||
mutableFloatStateOf(
|
mutableFloatStateOf(
|
||||||
with(density) {
|
with(density) {
|
||||||
when(noiseControlMode.value) {
|
when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -224,10 +184,10 @@ fun NoiseControlSettings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
val targetOffset = buttonWidth * when(noiseControlMode.value) {
|
||||||
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
|
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
|
||||||
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
|
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
|
||||||
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
|
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
|
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedOffset by animateFloatAsState(
|
val animatedOffset by animateFloatAsState(
|
||||||
@@ -250,7 +210,7 @@ fun NoiseControlSettings(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -323,13 +283,12 @@ fun NoiseControlSettings(
|
|||||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
||||||
val newIndex = position.roundToInt()
|
val newIndex = position.roundToInt()
|
||||||
val newMode = when(newIndex) {
|
val newMode = when(newIndex) {
|
||||||
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||||
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||||
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||||
3 -> NoiseControlMode.NOISE_CANCELLATION
|
3 -> NoiseControlMode.NOISE_CANCELLATION
|
||||||
else -> noiseControlMode.value // Keep current if index is invalid
|
else -> noiseControlMode.value // Keep current if index is invalid
|
||||||
}
|
}
|
||||||
// Call onModeSelected which now handles service call but not callback
|
|
||||||
onModeSelected(newMode)
|
onModeSelected(newMode)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -347,7 +306,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.zIndex(1f)
|
.zIndex(1f)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
NoiseControlButton(
|
NoiseControlButton(
|
||||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||||
@@ -406,7 +365,7 @@ fun NoiseControlSettings(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp)
|
.padding(top = 4.dp)
|
||||||
) {
|
) {
|
||||||
if (offListeningMode.value) {
|
if (showOffListeningMode) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.off),
|
text = stringResource(R.string.off),
|
||||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||||
@@ -436,9 +395,3 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun NoiseControlSettingsPreview() {
|
|
||||||
NoiseControlSettings(AirPodsService())
|
|
||||||
}
|
|
||||||
+9
-23
@@ -16,17 +16,13 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -35,38 +31,34 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PressAndHoldSettings(navController: NavController) {
|
fun PressAndHoldSettings(
|
||||||
|
navController: NavController,
|
||||||
|
leftAction: StemAction,
|
||||||
|
rightAction: StemAction
|
||||||
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val dividerColor = Color(0x40888888)
|
val dividerColor = Color(0x40888888)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val leftActionText = when (leftAction) {
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
|
||||||
|
|
||||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
}
|
}
|
||||||
|
|
||||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
val rightActionText = when (rightAction) {
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||||
else -> "INVALID!!"
|
else -> "INVALID!!"
|
||||||
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
||||||
@Composable
|
|
||||||
fun PressAndHoldSettingsPreview() {
|
|
||||||
PressAndHoldSettings(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
+87
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-22
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceAtMost
|
import androidx.compose.ui.util.fastCoerceAtMost
|
||||||
@@ -55,7 +57,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
import com.kyant.backdrop.Backdrop
|
import com.kyant.backdrop.Backdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refraction
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.vibrancy
|
import com.kyant.backdrop.effects.vibrancy
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -75,9 +77,12 @@ fun StyledButton(
|
|||||||
tint: Color = Color.Unspecified,
|
tint: Color = Color.Unspecified,
|
||||||
surfaceColor: Color = Color.Unspecified,
|
surfaceColor: Color = Color.Unspecified,
|
||||||
maxScale: Float = 0.1f,
|
maxScale: Float = 0.1f,
|
||||||
|
enabled: Boolean = true,
|
||||||
content: @Composable RowScope.() -> Unit,
|
content: @Composable RowScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val isInteractive = enabled && isInteractive
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
|
||||||
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
@@ -122,8 +127,8 @@ half4 main(float2 coord) {
|
|||||||
} else {
|
} else {
|
||||||
drawRect(Color.White.copy(0.1f))
|
drawRect(Color.White.copy(0.1f))
|
||||||
}
|
}
|
||||||
if (surfaceColor.isSpecified) {
|
if (surfaceColor.isSpecified && enabled) {
|
||||||
val color = if (!isInteractive && isPressed) {
|
val color = if (isPressed) {
|
||||||
Color(
|
Color(
|
||||||
red = surfaceColor.red * 0.5f,
|
red = surfaceColor.red * 0.5f,
|
||||||
green = surfaceColor.green * 0.5f,
|
green = surfaceColor.green * 0.5f,
|
||||||
@@ -134,6 +139,11 @@ half4 main(float2 coord) {
|
|||||||
surfaceColor
|
surfaceColor
|
||||||
}
|
}
|
||||||
drawRect(color)
|
drawRect(color)
|
||||||
|
} else {
|
||||||
|
if (isPressed) {
|
||||||
|
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||||
|
drawRect(Color.White.copy(alpha = 0.2f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDrawFront = null,
|
onDrawFront = null,
|
||||||
@@ -146,7 +156,12 @@ half4 main(float2 coord) {
|
|||||||
effects = {
|
effects = {
|
||||||
vibrancy()
|
vibrancy()
|
||||||
blur(2f.dp.toPx())
|
blur(2f.dp.toPx())
|
||||||
refraction(12f.dp.toPx(), 24f.dp.toPx())
|
lens(
|
||||||
|
refractionHeight = 12f.dp.toPx(),
|
||||||
|
refractionAmount = 24f.dp.toPx(),
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
layerBlock = {
|
layerBlock = {
|
||||||
val width = size.width
|
val width = size.width
|
||||||
@@ -158,19 +173,21 @@ 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)
|
||||||
scaleX =
|
scaleX =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||||
(width / height).fastCoerceAtMost(1f)
|
(width / height).fastCoerceAtMost(1f)
|
||||||
scaleY =
|
scaleY =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||||
(height / width).fastCoerceAtMost(1f)
|
(height / width).fastCoerceAtMost(1f)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
if (tint.isSpecified) {
|
if (tint.isSpecified) {
|
||||||
@@ -204,7 +221,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -231,31 +251,53 @@ half4 main(float2 coord) {
|
|||||||
interactionSource = null,
|
interactionSource = null,
|
||||||
indication = null,
|
indication = null,
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
onClick = onClick
|
onClick = {
|
||||||
|
if (enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.then(
|
.then(
|
||||||
if (isInteractive) {
|
if (isInteractive) {
|
||||||
Modifier.pointerInput(animationScope) {
|
Modifier.pointerInput(scope) {
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch {
|
||||||
|
offsetAnimation.animateTo(
|
||||||
|
Offset.Zero,
|
||||||
|
offsetAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
1f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { onDragStop() },
|
onDragEnd = {
|
||||||
|
onDragStop()
|
||||||
|
},
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,7 +311,10 @@ half4 main(float2 coord) {
|
|||||||
isPressed = false
|
isPressed = false
|
||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
onClick()
|
if (enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+17
-2
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
|
|||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@@ -110,6 +114,9 @@ fun StyledDropdown(
|
|||||||
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
val itemHeight = 48.dp
|
val itemHeight = 48.dp
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
|
||||||
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
|
||||||
|
|
||||||
@@ -132,7 +139,12 @@ fun StyledDropdown(
|
|||||||
},
|
},
|
||||||
onDrag = { change, _ ->
|
onDrag = { change, _ ->
|
||||||
val y = change.position.y
|
val y = change.position.y
|
||||||
hoveredIndex = (y / itemHeight.toPx()).toInt()
|
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
|
||||||
|
if (newHoveredIndex != hoveredIndex) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick) }
|
||||||
|
}
|
||||||
|
hoveredIndex = newHoveredIndex
|
||||||
lastDragPosition = change.position
|
lastDragPosition = change.position
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
@@ -144,6 +156,8 @@ fun StyledDropdown(
|
|||||||
if (withinBounds) {
|
if (withinBounds) {
|
||||||
hoveredIndex?.let { idx ->
|
hoveredIndex?.let { idx ->
|
||||||
if (idx in options.indices) {
|
if (idx in options.indices) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.GestureEnd) }
|
||||||
onOptionSelected(options[idx])
|
onOptionSelected(options[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +188,7 @@ fun StyledDropdown(
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) {
|
) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
onOptionSelected(text)
|
onOptionSelected(text)
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
}
|
}
|
||||||
+77
-31
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -50,7 +50,10 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
|
|||||||
import androidx.compose.ui.graphics.layer.drawLayer
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -63,8 +66,7 @@ import androidx.compose.ui.util.lerp
|
|||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -78,14 +80,17 @@ import kotlin.math.tanh
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledIconButton(
|
fun StyledIconButton(
|
||||||
onClick: () -> Unit,
|
|
||||||
icon: String,
|
|
||||||
darkMode: Boolean,
|
|
||||||
tint: Color = Color.Unspecified,
|
|
||||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
icon: String,
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
surfaceColor: Color = Color.Unspecified,
|
||||||
|
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val animationScope = rememberCoroutineScope()
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
@@ -94,6 +99,7 @@ fun StyledIconButton(
|
|||||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||||
compositingStrategy = CompositingStrategy.Offscreen
|
compositingStrategy = CompositingStrategy.Offscreen
|
||||||
}
|
}
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val interactiveHighlightShader = remember {
|
val interactiveHighlightShader = remember {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@@ -117,7 +123,12 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = onClick,
|
onClick = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
},
|
||||||
shape = RoundedCornerShape(56.dp),
|
shape = RoundedCornerShape(56.dp),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
@@ -132,6 +143,7 @@ half4 main(float2 coord) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
layerBlock = {
|
layerBlock = {
|
||||||
|
if (!enabled) return@drawBackdrop
|
||||||
val width = size.width
|
val width = size.width
|
||||||
val height = size.height
|
val height = size.height
|
||||||
|
|
||||||
@@ -148,14 +160,20 @@ half4 main(float2 coord) {
|
|||||||
val offsetAngle = atan2(offset.y, offset.x)
|
val offsetAngle = atan2(offset.y, offset.x)
|
||||||
scaleX =
|
scaleX =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
|
||||||
(width / height).fastCoerceAtMost(1f)
|
(width / height).fastCoerceAtMost(1f)
|
||||||
scaleY =
|
scaleY =
|
||||||
scale +
|
scale +
|
||||||
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
|
||||||
(height / width).fastCoerceAtMost(1f)
|
(height / width).fastCoerceAtMost(1f)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
|
if (!enabled) {
|
||||||
|
drawRect(
|
||||||
|
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
|
||||||
|
)
|
||||||
|
return@drawBackdrop
|
||||||
|
}
|
||||||
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
val progress = progressAnimation.value.coerceIn(0f, 1f)
|
||||||
|
|
||||||
val shape = RoundedCornerShape(56.dp)
|
val shape = RoundedCornerShape(56.dp)
|
||||||
@@ -182,11 +200,21 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
drawLayer(innerShadowLayer)
|
drawLayer(innerShadowLayer)
|
||||||
|
|
||||||
|
if (surfaceColor.isSpecified) {
|
||||||
|
drawRect(surfaceColor)
|
||||||
|
}
|
||||||
|
|
||||||
drawRect(
|
drawRect(
|
||||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
|
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||||
|
progress.coerceIn(
|
||||||
|
0.15f,
|
||||||
|
0.35f
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onDrawFront = {
|
onDrawFront = {
|
||||||
|
if (!enabled) return@drawBackdrop
|
||||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||||
if (progress > 0f) {
|
if (progress > 0f) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||||
@@ -197,7 +225,10 @@ half4 main(float2 coord) {
|
|||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset = pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
|
setColorUniform(
|
||||||
|
"color",
|
||||||
|
Color.White.copy(0.15f * progress).toArgb()
|
||||||
|
)
|
||||||
setFloatUniform("radius", size.maxDimension)
|
setFloatUniform("radius", size.maxDimension)
|
||||||
setFloatUniform(
|
setFloatUniform(
|
||||||
"offset",
|
"offset",
|
||||||
@@ -218,41 +249,56 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
// blur(24f, TileMode.Decal)
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.pointerInput(animationScope) {
|
.pointerInput(scope) {
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
animationScope.launch {
|
if (enabled) {
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
scope.launch {
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
|
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
||||||
|
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
if (enabled) {
|
||||||
animationScope.launch {
|
pressStartPosition = down.position
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
scope.launch {
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
|
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
||||||
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { onDragStop() },
|
onDragEnd = { onDragStop() },
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
if (enabled) {
|
||||||
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
|
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.size(48.dp),
|
.size(with(density) { 48.sp.toDp() }),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = icon,
|
text = icon,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
|
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.R.attr.singleLine
|
||||||
|
import androidx.compose.animation.core.animateDp
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StyledInputField(
|
||||||
|
inputState: TextFieldState,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
placeholder: String = "",
|
||||||
|
singleLine: Boolean = true
|
||||||
|
){
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val minHeight = if (singleLine) 58.dp else 120.dp
|
||||||
|
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
|
||||||
|
val hasText = inputState.text.isNotEmpty()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val spacerHeight by animateDpAsState(
|
||||||
|
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
|
||||||
|
label = "labelSpacer"
|
||||||
|
)
|
||||||
|
|
||||||
|
val transition = updateTransition(hasText, label = "floating")
|
||||||
|
val yOffset by transition.animateDp(label = "y") {
|
||||||
|
if (it) with (density) { (-48).sp.toDp() } else 0.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = verticalAlignment,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = minHeight)
|
||||||
|
.background(
|
||||||
|
backgroundColor,
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
state = inputState,
|
||||||
|
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = textColor,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(textColor),
|
||||||
|
decorator = { innerTextField ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
|
||||||
|
verticalAlignment = verticalAlignment,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor.copy(alpha = 0.8f)
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(y = yOffset)
|
||||||
|
)
|
||||||
|
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (singleLine && !inputState.text.isEmpty()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
inputState.clearText()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||||
|
alpha = 0.6f
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-11
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -49,7 +49,6 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
@@ -57,17 +56,15 @@ import dev.chrisbanes.haze.HazeProgressive
|
|||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.HazeTint
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
|
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
@@ -87,7 +84,7 @@ fun StyledScaffold(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
|
.padding(start = startPadding, end = endPadding)
|
||||||
) {
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
Box(
|
Box(
|
||||||
@@ -127,13 +124,12 @@ fun StyledScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content(topPadding + 64.dp, hazeState)
|
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -145,12 +141,11 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { _, _ ->
|
) { _, _, _->
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledScaffold(
|
fun StyledScaffold(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -162,7 +157,7 @@ fun StyledScaffold(
|
|||||||
title = title,
|
title = title,
|
||||||
actionButtons = actionButtons,
|
actionButtons = actionButtons,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerValue, _ ->
|
) { spacerValue, _, _ ->
|
||||||
content(spacerValue)
|
content(spacerValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-22
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -28,7 +28,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
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.fillMaxWidth
|
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.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -43,12 +43,13 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
@@ -59,19 +60,10 @@ data class SelectItem(
|
|||||||
val iconRes: Int? = null,
|
val iconRes: Int? = null,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
|
val visible: Boolean = true,
|
||||||
val enabled: Boolean = true
|
val enabled: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
data class SelectItem2(
|
|
||||||
val name: String,
|
|
||||||
val description: String? = null,
|
|
||||||
val iconRes: Int? = null,
|
|
||||||
val selected: () -> Boolean,
|
|
||||||
val onClick: () -> Unit,
|
|
||||||
val enabled: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSelectList(
|
fun StyledSelectList(
|
||||||
items: List<SelectItem>,
|
items: List<SelectItem>,
|
||||||
@@ -81,37 +73,48 @@ fun StyledSelectList(
|
|||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
.background(backgroundColor, RoundedCornerShape(28.dp)),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val visibleItems = items.filter { it.enabled }
|
val visibleItems = items.filter { it.visible }
|
||||||
visibleItems.forEachIndexed { index, item ->
|
visibleItems.forEachIndexed { index, item ->
|
||||||
val isFirst = index == 0
|
val isFirst = index == 0
|
||||||
val isLast = index == visibleItems.size - 1
|
val isLast = index == visibleItems.size - 1
|
||||||
val hasIcon = item.iconRes != null
|
val hasIcon = item.iconRes != null
|
||||||
|
|
||||||
val shape = when {
|
val shape = when {
|
||||||
|
isFirst && isLast -> RoundedCornerShape(28.dp)
|
||||||
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||||
else -> RoundedCornerShape(0.dp)
|
else -> RoundedCornerShape(0.dp)
|
||||||
}
|
}
|
||||||
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
|
var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(if (hasIcon) 72.dp else 55.dp)
|
.heightIn(min = if (hasIcon) 72.dp else 55.dp)
|
||||||
.background(animatedBackgroundColor, shape)
|
.background(animatedBackgroundColor, shape)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
onPress = {
|
onPress = {
|
||||||
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
if (item.enabled) {
|
||||||
tryAwaitRelease()
|
itemBackgroundColor =
|
||||||
itemBackgroundColor = backgroundColor
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
item.onClick()
|
tryAwaitRelease()
|
||||||
|
itemBackgroundColor = backgroundColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (item.enabled) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||||
|
item.onClick()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,11 +124,11 @@ fun StyledSelectList(
|
|||||||
) {
|
) {
|
||||||
if (hasIcon) {
|
if (hasIcon) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(item.iconRes!!),
|
painter = painterResource(item.iconRes),
|
||||||
contentDescription = "Icon",
|
contentDescription = "Icon",
|
||||||
tint = Color(0xFF007AFF),
|
tint = Color(0xFF007AFF),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.wrapContentWidth()
|
.wrapContentWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+125
-81
@@ -16,8 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableFloatState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||||
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.layout.positionInParent
|
import androidx.compose.ui.layout.positionInParent
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -81,7 +83,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.blur
|
import com.kyant.backdrop.effects.blur
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.InnerShadow
|
import com.kyant.backdrop.shadow.InnerShadow
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
@@ -203,10 +205,11 @@ class MomentumAnimation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnrememberedMutableState")
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSlider(
|
fun StyledSlider(
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
mutableFloatState: MutableFloatState,
|
value: Float,
|
||||||
onValueChange: (Float) -> Unit,
|
onValueChange: (Float) -> Unit,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||||
@@ -217,23 +220,26 @@ fun StyledSlider(
|
|||||||
startLabel: String? = null,
|
startLabel: String? = null,
|
||||||
endLabel: String? = null,
|
endLabel: String? = null,
|
||||||
independent: Boolean = false,
|
independent: Boolean = false,
|
||||||
description: String? = null
|
description: String? = null,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val isLightTheme = !isSystemInDarkTheme()
|
val isLightTheme = !isSystemInDarkTheme()
|
||||||
val accentColor =
|
|
||||||
if (isLightTheme) Color(0xFF0088FF)
|
|
||||||
else Color(0xFF0091FF)
|
|
||||||
val trackColor =
|
val trackColor =
|
||||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
||||||
else Color(0xFF787880).copy(0.36f)
|
else Color(0xFF787880).copy(0.36f)
|
||||||
|
val accentColor =
|
||||||
|
if (enabled) {
|
||||||
|
if (isLightTheme) Color(0xFF0088FF)
|
||||||
|
else Color(0xFF0091FF)
|
||||||
|
} else {
|
||||||
|
trackColor
|
||||||
|
}
|
||||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
||||||
|
|
||||||
val fraction by remember {
|
val fraction by derivedStateOf {
|
||||||
derivedStateOf {
|
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||||
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
.fastCoerceIn(0f, 1f)
|
||||||
.fastCoerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sliderBackdrop = rememberLayerBackdrop()
|
val sliderBackdrop = rememberLayerBackdrop()
|
||||||
@@ -242,6 +248,8 @@ fun StyledSlider(
|
|||||||
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
val startIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
val endIconWidthState = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var lastDragValue by remember { mutableFloatStateOf(value) }
|
||||||
|
|
||||||
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
|
||||||
|
|
||||||
@@ -427,71 +435,93 @@ fun StyledSlider(
|
|||||||
)
|
)
|
||||||
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
|
||||||
}
|
}
|
||||||
.draggable(
|
.then(
|
||||||
rememberDraggableState { delta ->
|
if (enabled) {
|
||||||
val trackWidth = trackWidthState.floatValue
|
Modifier
|
||||||
if (trackWidth > 0f) {
|
.draggable(
|
||||||
val targetFraction = fraction + delta / trackWidth
|
rememberDraggableState { delta ->
|
||||||
val targetValue =
|
val trackWidth = trackWidthState.floatValue
|
||||||
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
|
if (trackWidth > 0f) {
|
||||||
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
|
val targetFraction = fraction + delta / trackWidth
|
||||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
val targetValue =
|
||||||
targetValue,
|
lerp(
|
||||||
snapPoints,
|
valueRange.start,
|
||||||
snapThreshold
|
valueRange.endInclusive,
|
||||||
) else targetValue
|
targetFraction
|
||||||
onValueChange(snappedValue)
|
)
|
||||||
}
|
.fastCoerceIn(
|
||||||
},
|
valueRange.start,
|
||||||
Orientation.Horizontal,
|
valueRange.endInclusive
|
||||||
startDragImmediately = true,
|
)
|
||||||
onDragStarted = {
|
snapPoints.forEach { snap ->
|
||||||
// Remove this block as momentumAnimation handles pressing
|
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||||
},
|
(snap in targetValue..<lastDragValue)) {
|
||||||
onDragStopped = {
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
// Remove this block as momentumAnimation handles pressing
|
}
|
||||||
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
|
}
|
||||||
}
|
lastDragValue = targetValue
|
||||||
)
|
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
||||||
.then(momentumAnimation.modifier)
|
targetValue,
|
||||||
.drawBackdrop(
|
snapPoints,
|
||||||
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
snapThreshold,
|
||||||
{ RoundedCornerShape(28.dp) },
|
) else targetValue
|
||||||
highlight = {
|
onValueChange(snappedValue)
|
||||||
val progress = momentumAnimation.progress
|
}
|
||||||
Highlight.Ambient.copy(alpha = progress)
|
},
|
||||||
},
|
Orientation.Horizontal,
|
||||||
shadow = {
|
startDragImmediately = true,
|
||||||
Shadow(
|
onDragStarted = {
|
||||||
radius = 4f.dp,
|
lastDragValue = value
|
||||||
color = Color.Black.copy(0.05f)
|
},
|
||||||
)
|
onDragStopped = {
|
||||||
},
|
onValueChange((value * 100).roundToInt() / 100f)
|
||||||
innerShadow = {
|
}
|
||||||
val progress = momentumAnimation.progress
|
)
|
||||||
InnerShadow(
|
.then(momentumAnimation.modifier)
|
||||||
radius = 4f.dp * progress,
|
.drawBackdrop(
|
||||||
alpha = progress
|
rememberCombinedBackdrop(backdrop, sliderBackdrop),
|
||||||
)
|
{ RoundedCornerShape(28.dp) },
|
||||||
},
|
highlight = {
|
||||||
layerBlock = {
|
val progress = momentumAnimation.progress
|
||||||
scaleX = momentumAnimation.scaleX
|
Highlight.Ambient.copy(alpha = progress)
|
||||||
scaleY = momentumAnimation.scaleY
|
},
|
||||||
val velocity = momentumAnimation.velocity / 5000f
|
shadow = {
|
||||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
Shadow(
|
||||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
radius = 4f.dp,
|
||||||
},
|
color = Color.Black.copy(0.05f)
|
||||||
onDrawSurface = {
|
)
|
||||||
val progress = momentumAnimation.progress
|
},
|
||||||
drawRect(Color.White.copy(alpha = 1f - progress))
|
innerShadow = {
|
||||||
},
|
val progress = momentumAnimation.progress
|
||||||
effects = {
|
InnerShadow(
|
||||||
val progress = momentumAnimation.progress
|
radius = 4f.dp * progress,
|
||||||
blur(8f.dp.toPx() * (1f - progress))
|
alpha = progress
|
||||||
refractionWithDispersion(
|
)
|
||||||
height = 6f.dp.toPx() * progress,
|
},
|
||||||
amount = size.height / 2f * progress
|
layerBlock = {
|
||||||
)
|
scaleX = momentumAnimation.scaleX
|
||||||
|
scaleY = momentumAnimation.scaleY
|
||||||
|
val velocity = momentumAnimation.velocity / 5000f
|
||||||
|
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
drawRect(Color.White.copy(alpha = 1f - progress))
|
||||||
|
},
|
||||||
|
effects = {
|
||||||
|
val progress = momentumAnimation.progress
|
||||||
|
blur(8f.dp.toPx() * (1f - progress))
|
||||||
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx() * progress,
|
||||||
|
refractionAmount = size.height / 2f * progress,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Modifier.background(trackColor, RoundedCornerShape(28.dp))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.size(40f.dp, 24f.dp)
|
.size(40f.dp, 24f.dp)
|
||||||
@@ -566,12 +596,13 @@ fun StyledSliderPreview() {
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
Box (
|
Column (
|
||||||
Modifier.align(Alignment.Center)
|
Modifier.align(Alignment.Center),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
mutableFloatState = a,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
a.floatValue = it
|
a.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -582,6 +613,19 @@ fun StyledSliderPreview() {
|
|||||||
startIcon = "A",
|
startIcon = "A",
|
||||||
endIcon = "B",
|
endIcon = "B",
|
||||||
)
|
)
|
||||||
|
StyledSlider(
|
||||||
|
value = a.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
a.floatValue = it
|
||||||
|
},
|
||||||
|
valueRange = 0f..2f,
|
||||||
|
snapPoints = listOf(1f),
|
||||||
|
snapThreshold = 0.1f,
|
||||||
|
independent = true,
|
||||||
|
startIcon = "A",
|
||||||
|
endIcon = "B",
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
-17
@@ -16,10 +16,10 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.animation.Animatable
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||||
import androidx.compose.ui.graphics.layer.drawLayer
|
import androidx.compose.ui.graphics.layer.drawLayer
|
||||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.fastCoerceIn
|
import androidx.compose.ui.util.fastCoerceIn
|
||||||
@@ -68,11 +70,12 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.refractionWithDispersion
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
import com.kyant.backdrop.shadow.Shadow
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledSwitch(
|
fun StyledSwitch(
|
||||||
@@ -81,9 +84,12 @@ fun StyledSwitch(
|
|||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||||
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
|
||||||
|
0x805B5B5E
|
||||||
|
) else Color(0xFFD1D1D6)
|
||||||
|
|
||||||
val trackWidth = 64.dp
|
val trackWidth = 64.dp
|
||||||
val trackHeight = 28.dp
|
val trackHeight = 28.dp
|
||||||
@@ -98,24 +104,25 @@ fun StyledSwitch(
|
|||||||
val animatedFraction = remember { Animatable(fraction) }
|
val animatedFraction = remember { Animatable(fraction) }
|
||||||
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
val trackWidthPx = remember { mutableFloatStateOf(0f) }
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val animationScope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
|
|
||||||
val progressAnimation = remember { Animatable(0f) }
|
val progressAnimation = remember { Animatable(0f) }
|
||||||
val innerShadowLayer = rememberGraphicsLayer().apply {
|
val innerShadowLayer = rememberGraphicsLayer().apply {
|
||||||
compositingStrategy = CompositingStrategy.Offscreen
|
compositingStrategy = CompositingStrategy.Offscreen
|
||||||
}
|
}
|
||||||
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
|
val targetColor = if (checked) onColor else offColor
|
||||||
|
val animatedTrackColor by animateColorAsState(targetColor)
|
||||||
val totalDrag = remember { mutableFloatStateOf(0f) }
|
val totalDrag = remember { mutableFloatStateOf(0f) }
|
||||||
val tapThreshold = 10f
|
val tapThreshold = 10f
|
||||||
val isFirstComposition = remember { mutableStateOf(true) }
|
val isFirstComposition = remember { mutableStateOf(true) }
|
||||||
LaunchedEffect(checked) {
|
LaunchedEffect(checked) {
|
||||||
if (!isFirstComposition.value) {
|
if (!isFirstComposition.value) {
|
||||||
|
if (checked) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
|
||||||
|
} else {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
|
||||||
|
}
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch {
|
|
||||||
val targetColor = if (checked) onColor else offColor
|
|
||||||
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
|
|
||||||
}
|
|
||||||
launch {
|
launch {
|
||||||
val targetFrac = if (checked) 1f else 0f
|
val targetFrac = if (checked) 1f else 0f
|
||||||
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
|
||||||
@@ -140,7 +147,7 @@ fun StyledSwitch(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(switchBackdrop)
|
.layerBackdrop(switchBackdrop)
|
||||||
.clip(RoundedCornerShape(trackHeight / 2))
|
.clip(RoundedCornerShape(trackHeight / 2))
|
||||||
.background(animatedTrackColor.value)
|
.background(animatedTrackColor)
|
||||||
.width(trackWidth)
|
.width(trackWidth)
|
||||||
.height(trackHeight)
|
.height(trackHeight)
|
||||||
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
|
||||||
@@ -154,27 +161,31 @@ fun StyledSwitch(
|
|||||||
.then(if (enabled) Modifier.draggable(
|
.then(if (enabled) Modifier.draggable(
|
||||||
rememberDraggableState { delta ->
|
rememberDraggableState { delta ->
|
||||||
if (trackWidthPx.floatValue > 0f) {
|
if (trackWidthPx.floatValue > 0f) {
|
||||||
|
val oldFraction = animatedFraction.value
|
||||||
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
animatedFraction.snapTo(newFraction)
|
animatedFraction.snapTo(newFraction)
|
||||||
}
|
}
|
||||||
totalDrag.floatValue += kotlin.math.abs(delta)
|
totalDrag.floatValue += abs(delta)
|
||||||
val newChecked = newFraction >= 0.5f
|
val newChecked = newFraction >= 0.5f
|
||||||
if (newChecked != checked) {
|
if (newChecked != checked) {
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
}
|
}
|
||||||
|
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Orientation.Horizontal,
|
Orientation.Horizontal,
|
||||||
startDragImmediately = true,
|
startDragImmediately = true,
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
totalDrag.floatValue = 0f
|
totalDrag.floatValue = 0f
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
progressAnimation.animateTo(1f, progressAnimationSpec)
|
progressAnimation.animateTo(1f, progressAnimationSpec)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
animationScope.launch {
|
scope.launch {
|
||||||
if (totalDrag.floatValue < tapThreshold) {
|
if (totalDrag.floatValue < tapThreshold) {
|
||||||
val newChecked = !checked
|
val newChecked = !checked
|
||||||
onCheckedChange(newChecked)
|
onCheckedChange(newChecked)
|
||||||
@@ -262,7 +273,12 @@ fun StyledSwitch(
|
|||||||
drawRect(Color.White.copy(1f - progress))
|
drawRect(Color.White.copy(1f - progress))
|
||||||
},
|
},
|
||||||
effects = {
|
effects = {
|
||||||
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
|
lens(
|
||||||
|
refractionHeight = 6f.dp.toPx(),
|
||||||
|
refractionAmount = size.height / 2f,
|
||||||
|
depthEffect = true,
|
||||||
|
chromaticAberration = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.width(thumbWidth)
|
.width(thumbWidth)
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StyledToggle(
|
||||||
|
title: String? = null,
|
||||||
|
label: String,
|
||||||
|
description: String? = null,
|
||||||
|
checked: Boolean = false,
|
||||||
|
independent: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val currentChecked by rememberUpdatedState(checked)
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var backgroundColor by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatedBackgroundColor by animateColorAsState(
|
||||||
|
targetValue = backgroundColor,
|
||||||
|
animationSpec = tween(durationMillis = 500)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (independent) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
|
if (title != null) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 4.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(4.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
if (enabled) {
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
|
tryAwaitRelease()
|
||||||
|
backgroundColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(55.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.background(
|
||||||
|
if (isDarkTheme) Color(0xFF000000)
|
||||||
|
else Color(0xFFF2F2F7)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val isPressed = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
||||||
|
)
|
||||||
|
.padding(16.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onPress = {
|
||||||
|
isPressed.value = true
|
||||||
|
tryAwaitRelease()
|
||||||
|
isPressed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() }
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
||||||
|
onCheckedChange(!currentChecked)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSwitch(
|
||||||
|
checked = checked,
|
||||||
|
enabled = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled) {
|
||||||
|
onCheckedChange(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun StyledTogglePreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
StyledToggle(
|
||||||
|
label = "Example Toggle",
|
||||||
|
description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
+11
-6
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
|||||||
import androidx.dynamicanimation.animation.SpringAnimation
|
import androidx.dynamicanimation.animation.SpringAnimation
|
||||||
import androidx.dynamicanimation.animation.SpringForce
|
import androidx.dynamicanimation.animation.SpringForce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
|
|
||||||
containerView.addView(islandView, containerParams)
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
params = WindowManager.LayoutParams(
|
params = WindowManager.LayoutParams(
|
||||||
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
|
|||||||
videoView.start()
|
videoView.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
windowManager.addView(containerView, params)
|
try {
|
||||||
|
windowManager.addView(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
islandView.post {
|
islandView.post {
|
||||||
initialHeight = islandView.height
|
initialHeight = islandView.height
|
||||||
+18
-9
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.presentation.overlays
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.constants.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
|
||||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||||
class PopupWindow(
|
class PopupWindow(
|
||||||
@@ -64,10 +64,16 @@ class PopupWindow(
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||||
height = WindowManager.LayoutParams.WRAP_CONTENT
|
height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
val displayMetrics = context.resources.displayMetrics
|
||||||
|
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
|
||||||
|
width = if (screenWidthDp >= 600) {
|
||||||
|
(400 * displayMetrics.density).toInt()
|
||||||
|
} else {
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||||
format = PixelFormat.TRANSLUCENT
|
format = PixelFormat.TRANSLUCENT
|
||||||
gravity = Gravity.BOTTOM
|
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||||
dimAmount = 0.3f
|
dimAmount = 0.3f
|
||||||
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
|
||||||
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
WindowManager.LayoutParams.FLAG_FULLSCREEN or
|
||||||
@@ -84,7 +90,6 @@ class PopupWindow(
|
|||||||
mParams.x = 0
|
mParams.x = 0
|
||||||
mParams.y = 0
|
mParams.y = 0
|
||||||
|
|
||||||
mParams.gravity = Gravity.BOTTOM
|
|
||||||
mView.setOnClickListener {
|
mView.setOnClickListener {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -139,7 +144,11 @@ class PopupWindow(
|
|||||||
vid.start()
|
vid.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
mWindowManager.addView(mView, mParams)
|
try {
|
||||||
|
mWindowManager.addView(mView, mParams)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
val displayMetrics = mView.context.resources.displayMetrics
|
val displayMetrics = mView.context.resources.displayMetrics
|
||||||
val screenHeight = displayMetrics.heightPixels
|
val screenHeight = displayMetrics.heightPixels
|
||||||
+426
-422
File diff suppressed because it is too large
Load Diff
+130
@@ -0,0 +1,130 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.customize_adaptive_audio)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sliderValue = remember {
|
||||||
|
mutableFloatStateOf(
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toFloat() ?: 50f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var job by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.customize_adaptive_audio),
|
||||||
|
value = sliderValue.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
sliderValue.floatValue = it
|
||||||
|
job?.cancel()
|
||||||
|
job = scope.launch {
|
||||||
|
delay(150)
|
||||||
|
viewModel.setControlCommandValue(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||||
|
byteArrayOf((100 - it).toInt().toByte())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(0f, 50f, 100f),
|
||||||
|
startIcon = "",
|
||||||
|
endIcon = "",
|
||||||
|
independent = true,
|
||||||
|
description = stringResource(R.string.adaptive_audio_description),
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+537
@@ -0,0 +1,537 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context.MODE_PRIVATE
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsPro3
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AboutCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AudioSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.BatteryView
|
||||||
|
import me.kavishdevar.librepods.presentation.components.CallControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
|
@Composable
|
||||||
|
fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
var deviceName by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
TextFieldValue(
|
||||||
|
sharedPreferences.getString("name", state.deviceName).toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameChangeListener = remember {
|
||||||
|
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
if (key == "name") {
|
||||||
|
deviceName =
|
||||||
|
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
onDispose {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.refreshInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
isSystemInDarkTheme()
|
||||||
|
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = deviceName.text, actionButtons = listOf(
|
||||||
|
{ scaffoldBackdrop ->
|
||||||
|
StyledIconButton(
|
||||||
|
onClick = { navController.navigate("app_settings") },
|
||||||
|
icon = "",
|
||||||
|
backdrop = scaffoldBackdrop
|
||||||
|
)
|
||||||
|
}), snackbarHostState = snackbarHostState
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
hazeStateS.value = hazeState
|
||||||
|
var blockTouches by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.demoActivated.collect {
|
||||||
|
blockTouches = true
|
||||||
|
delay(1000)
|
||||||
|
blockTouches = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLocallyConnected) {
|
||||||
|
val capabilities = state.capabilities
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(if (blockTouches) Modifier.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
event.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else Modifier)) {
|
||||||
|
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
|
||||||
|
item(key = "battery") {
|
||||||
|
BatteryView(
|
||||||
|
batteryList = state.battery,
|
||||||
|
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
|
||||||
|
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
|
||||||
|
item(key = "name") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "rename",
|
||||||
|
name = stringResource(R.string.name),
|
||||||
|
currentState = deviceName.text,
|
||||||
|
navController = navController,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasHearingAidCapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
|
||||||
|
val hasPPECapability =
|
||||||
|
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
|
||||||
|
|
||||||
|
if (hasHearingAidCapability || hasPPECapability) {
|
||||||
|
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
|
||||||
|
key = "spacer_hearing_health"
|
||||||
|
) { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
|
item(key = "hearing_health") {
|
||||||
|
HearingHealthSettings(
|
||||||
|
navController = navController,
|
||||||
|
hasPPECapability = hasPPECapability,
|
||||||
|
hasHearingAidCapability = hasHearingAidCapability,
|
||||||
|
vendorIdHook = state.vendorIdHook
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
||||||
|
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "noise_control") {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = state.offListeningMode,
|
||||||
|
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() ?: 3,
|
||||||
|
onNoiseControlModeChanged = {
|
||||||
|
viewModel.setControlCommandInt(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
||||||
|
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "press_hold") {
|
||||||
|
PressAndHoldSettings(
|
||||||
|
navController = navController,
|
||||||
|
leftAction = state.leftAction,
|
||||||
|
rightAction = state.rightAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "call_control") {
|
||||||
|
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||||
|
val flipped = bytes[1] == 0x02.toByte()
|
||||||
|
CallControlSettings(
|
||||||
|
hazeState = hazeState,
|
||||||
|
flipped = flipped,
|
||||||
|
onCallControlValueChanged = {
|
||||||
|
viewModel.setControlCommandValue(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||||
|
if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
|
||||||
|
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
// item(key = "camera_control") {
|
||||||
|
// NavigationButton(
|
||||||
|
// to = "camera_control",
|
||||||
|
// name = stringResource(R.string.camera_remote),
|
||||||
|
// description = stringResource(R.string.camera_control_description),
|
||||||
|
// title = stringResource(R.string.camera_control),
|
||||||
|
// navController = navController
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
item(key = "upgrade_button") {
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
|
||||||
|
0xFFE59900
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "audio") {
|
||||||
|
val model = state.instance?.model ?: AirPodsPro3()
|
||||||
|
val adaptiveVolumeCapability =
|
||||||
|
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
|
||||||
|
val conversationalAwarenessCapability =
|
||||||
|
model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
|
||||||
|
val loudSoundReductionCapability =
|
||||||
|
model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
|
||||||
|
val adaptiveAudioCapability =
|
||||||
|
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
|
||||||
|
|
||||||
|
val adaptiveVolumeChecked =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
val conversationalAwarenessChecked =
|
||||||
|
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
|
||||||
|
AudioSettings(
|
||||||
|
navController = navController,
|
||||||
|
adaptiveVolumeCapability = adaptiveVolumeCapability,
|
||||||
|
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
||||||
|
loudSoundReductionCapability = loudSoundReductionCapability,
|
||||||
|
adaptiveAudioCapability = adaptiveAudioCapability,
|
||||||
|
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
||||||
|
onAdaptiveVolumeCheckedChange = { checked ->
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
|
||||||
|
onConversationalAwarenessCheckedChange = { checked ->
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loudSoundReductionChecked = state.loudSoundReductionEnabled,
|
||||||
|
onLoudSoundReductionCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
vendorIdHook = state.vendorIdHook,
|
||||||
|
isPremium = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "connection") {
|
||||||
|
ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
|
||||||
|
onAutomaticEarDetectionChanged = {
|
||||||
|
viewModel.setAutomaticEarDetectionEnabled(it)
|
||||||
|
},
|
||||||
|
automaticConnectionEnabled = state.automaticConnectionEnabled,
|
||||||
|
onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "microphone") {
|
||||||
|
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
|
||||||
|
MicrophoneSettings(
|
||||||
|
hazeState = hazeState,
|
||||||
|
micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
|
||||||
|
onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
||||||
|
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "sleep_detection") {
|
||||||
|
val id =
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.sleep_detection),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(id, it)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
||||||
|
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "head_tracking") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "head_tracking",
|
||||||
|
name = stringResource(R.string.head_gestures),
|
||||||
|
navController = navController,
|
||||||
|
currentState = if (sharedPreferences.getBoolean(
|
||||||
|
"head_gestures", false
|
||||||
|
)
|
||||||
|
) stringResource(R.string.on) else stringResource(R.string.off)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "accessibility") {
|
||||||
|
NavigationButton(
|
||||||
|
to = "accessibility",
|
||||||
|
name = stringResource(R.string.accessibility),
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
|
||||||
|
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "off_listening") {
|
||||||
|
val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.off_listening_mode),
|
||||||
|
description = stringResource(R.string.off_listening_mode_description),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = viewModel::setOffListeningMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
item(key = "about") {
|
||||||
|
AboutCard(
|
||||||
|
navController = navController,
|
||||||
|
modelName = state.modelName,
|
||||||
|
actualModel = state.actualModel,
|
||||||
|
serialNumbers = state.serialNumbers,
|
||||||
|
version = state.version3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
||||||
|
item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.drawBackdrop(
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
exportedBackdrop = backdrop,
|
||||||
|
shape = { RoundedCornerShape(0.dp) },
|
||||||
|
highlight = {
|
||||||
|
Highlight.Ambient.copy(alpha = 0f)
|
||||||
|
},
|
||||||
|
effects = {})
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
val tapCount = remember { mutableIntStateOf(0) }
|
||||||
|
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (now - lastTapTime.longValue > 400) {
|
||||||
|
tapCount.intValue = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
tapCount.intValue++
|
||||||
|
lastTapTime.longValue = now
|
||||||
|
|
||||||
|
if (tapCount.intValue >= 5) {
|
||||||
|
tapCount.intValue = 0
|
||||||
|
viewModel.activateDemoMode()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.airpods_not_connected), style = TextStyle(
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.airpods_not_connected_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = { navController.navigate("troubleshooting") },
|
||||||
|
backdrop = backdrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.troubleshooting),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.reconnectFromSavedMac()
|
||||||
|
}, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reconnect_to_last_device), style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+687
@@ -0,0 +1,687 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||||
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppSettingsScreen(
|
||||||
|
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
val contactBottomSheet = remember { mutableStateOf(false) }
|
||||||
|
val subjectState = remember { TextFieldState() }
|
||||||
|
val descriptionState = remember { TextFieldState() }
|
||||||
|
val subjectFocusRequester = remember { FocusRequester() }
|
||||||
|
val descriptionFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.settings)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
if (!state.isPremium && state.connectionSuccessful) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.connectionSuccessful) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.widget),
|
||||||
|
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||||
|
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||||
|
checked = state.showPhoneBatteryInWidget,
|
||||||
|
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||||
|
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||||
|
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||||
|
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||||
|
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||||
|
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||||
|
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||||
|
LaunchedEffect(conversationalAwarenessVolume) {
|
||||||
|
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.conversational_awareness_volume),
|
||||||
|
value = conversationalAwarenessVolume,
|
||||||
|
valueRange = 10f..85f,
|
||||||
|
snapPoints = listOf(44f),
|
||||||
|
startLabel = "10%",
|
||||||
|
endLabel = "85%",
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
viewModel.setConversationalAwarenessVolume(
|
||||||
|
newValue
|
||||||
|
)
|
||||||
|
},
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
// if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
// Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
//
|
||||||
|
// NavigationButton(
|
||||||
|
// to = "",
|
||||||
|
// title = stringResource(R.string.camera_control),
|
||||||
|
// name = stringResource(R.string.set_custom_camera_package),
|
||||||
|
// navController = navController,
|
||||||
|
// onClick = {
|
||||||
|
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||||
|
// },
|
||||||
|
// independent = true,
|
||||||
|
// description = stringResource(R.string.camera_control_app_description)
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.ear_detection),
|
||||||
|
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||||
|
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||||
|
checked = state.disconnectWhenNotWearing,
|
||||||
|
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_disconnected),
|
||||||
|
description = stringResource(R.string.takeover_disconnected_desc),
|
||||||
|
checked = state.takeoverWhenDisconnected,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_idle),
|
||||||
|
description = stringResource(R.string.takeover_idle_desc),
|
||||||
|
checked = state.takeoverWhenIdle,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_music),
|
||||||
|
description = stringResource(R.string.takeover_music_desc),
|
||||||
|
checked = state.takeoverWhenMusic,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_call),
|
||||||
|
description = stringResource(R.string.takeover_call_desc),
|
||||||
|
checked = state.takeoverWhenCall,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_ringing_call),
|
||||||
|
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||||
|
checked = state.takeoverWhenRingingCall,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.takeover_media_start),
|
||||||
|
description = stringResource(R.string.takeover_media_start_desc),
|
||||||
|
checked = state.takeoverWhenMediaStart,
|
||||||
|
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||||
|
independent = false,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_options), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||||
|
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||||
|
checked = state.useAlternateHeadTrackingPackets,
|
||||||
|
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 16.dp, bottom = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations_unavailable),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
|
val restartBluetoothText =
|
||||||
|
stringResource(R.string.found_offset_restart_bluetooth)
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||||
|
stringResource(
|
||||||
|
R.string.requires_xposed
|
||||||
|
)
|
||||||
|
})",
|
||||||
|
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||||
|
checked = state.vendorIdHook,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.setVendorIdHook(enabled)
|
||||||
|
},
|
||||||
|
independent = true,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BuildConfig.PLAY_BUILD) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
NavigationButton(
|
||||||
|
to = "troubleshooting",
|
||||||
|
name = stringResource(R.string.troubleshooting),
|
||||||
|
navController = navController,
|
||||||
|
independent = true,
|
||||||
|
description = stringResource(R.string.troubleshooting_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
||||||
|
.padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.contact), style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
backgroundColor, RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.email),
|
||||||
|
navController = navController,
|
||||||
|
onClick = { contactBottomSheet.value = true },
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.discord),
|
||||||
|
navController = navController,
|
||||||
|
onClick = {
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "",
|
||||||
|
name = stringResource(R.string.github_issues),
|
||||||
|
navController = navController,
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://github.com/kavishdevar/librepods/issues".toUri()
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
},
|
||||||
|
independent = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
DeviceInfoCard()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
AppInfoCard()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "open_source_licenses",
|
||||||
|
name = stringResource(R.string.open_source_licenses),
|
||||||
|
navController = navController,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
|
||||||
|
if (state.showCameraDialog) {
|
||||||
|
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.set_custom_camera_package),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}, text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.enter_custom_camera_package),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.cameraPackageValue,
|
||||||
|
onValueChange = {
|
||||||
|
viewModel.setCameraPackageValue(it)
|
||||||
|
viewModel.setCameraPackageError(null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = state.cameraPackageError != null,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
capitalization = KeyboardCapitalization.None
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
|
||||||
|
0xFF3C6DF5
|
||||||
|
),
|
||||||
|
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||||
|
),
|
||||||
|
supportingText = {
|
||||||
|
if (state.cameraPackageError != null) {
|
||||||
|
Text(
|
||||||
|
state.cameraPackageError ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.custom_camera_package)) })
|
||||||
|
}
|
||||||
|
}, confirmButton = {
|
||||||
|
val successText = stringResource(R.string.custom_camera_package_set_success)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveCameraPackage()
|
||||||
|
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
"Save",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.setShowCameraDialog(false) }) {
|
||||||
|
Text(
|
||||||
|
"Cancel",
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StyledBottomSheet(
|
||||||
|
visible = contactBottomSheet.value,
|
||||||
|
onDismiss = { contactBottomSheet.value = false },
|
||||||
|
backdrop = backdrop
|
||||||
|
) { innerBackdrop, progress ->
|
||||||
|
val animatedPadding = lerp(16.dp, 2.dp, progress)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = animatedPadding)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
StyledIconButton(
|
||||||
|
icon = "\uDBC0\uDD84",
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
onClick = { contactBottomSheet.value = false }
|
||||||
|
)
|
||||||
|
Text (
|
||||||
|
text = stringResource(R.string.describe_your_issue),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
)
|
||||||
|
StyledIconButton(
|
||||||
|
icon = "\uDBC0\uDE1F",
|
||||||
|
backdrop = innerBackdrop,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
|
||||||
|
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
|
||||||
|
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
|
||||||
|
onClick = {
|
||||||
|
contactBottomSheet.value = false
|
||||||
|
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:".toUri()
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
"${descriptionState.text}" +
|
||||||
|
"\n\n----------" +
|
||||||
|
"\nPhone details:" +
|
||||||
|
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
|
||||||
|
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
|
||||||
|
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
|
||||||
|
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
|
||||||
|
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
|
||||||
|
"\n\nApp details:" +
|
||||||
|
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
|
||||||
|
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
|
||||||
|
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
|
||||||
|
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
subjectState.clearText()
|
||||||
|
descriptionState.clearText()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
inputState = subjectState,
|
||||||
|
focusRequester = subjectFocusRequester,
|
||||||
|
placeholder = stringResource(R.string.subject),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
inputState = descriptionState,
|
||||||
|
focusRequester = descriptionFocusRequester,
|
||||||
|
placeholder = stringResource(R.string.describe_your_issue),
|
||||||
|
singleLine = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityServiceInfo
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.accessibility.AccessibilityManager
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
|
import me.kavishdevar.librepods.services.AppListenerService
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||||
|
|
||||||
|
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
||||||
|
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
||||||
|
val enabledServices =
|
||||||
|
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
||||||
|
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
||||||
|
return enabledServices.any {
|
||||||
|
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
|
||||||
|
it.resolveInfo.serviceInfo.name == serviceComponent.className
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSelection(action: StemPressType?) {
|
||||||
|
if (action != null && !isAppListenerServiceEnabled(context)) {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||||
|
} else {
|
||||||
|
viewModel.setCameraAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cameraOptions = remember(currentCameraAction) {
|
||||||
|
listOf(
|
||||||
|
SelectItem(
|
||||||
|
name = "Off",
|
||||||
|
selected = currentCameraAction == null,
|
||||||
|
onClick = { handleSelection(null) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press once",
|
||||||
|
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
|
||||||
|
),
|
||||||
|
SelectItem(
|
||||||
|
name = "Press and hold AirPods",
|
||||||
|
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
||||||
|
onClick = { handleSelection(StemPressType.LONG_PRESS) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.camera_control)
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
StyledSelectList(items = cameraOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-8
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -335,12 +335,11 @@ fun DebugScreen(navController: NavController) {
|
|||||||
expandedItems.value = emptySet()
|
expandedItems.value = emptySet()
|
||||||
},
|
},
|
||||||
icon = "",
|
icon = "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -349,7 +348,7 @@ fun DebugScreen(navController: NavController) {
|
|||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
+90
-83
@@ -21,12 +21,13 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.graphics.Paint
|
||||||
import android.os.Build
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -45,6 +46,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -74,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
|
|||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.nativeCanvas
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.drawText
|
import androidx.compose.ui.text.drawText
|
||||||
@@ -83,9 +84,9 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
@@ -94,12 +95,13 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.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.cos
|
||||||
@@ -107,14 +109,14 @@ import kotlin.math.sin
|
|||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HeadTrackingScreen() {
|
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
onDispose {
|
onDispose {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -127,25 +129,22 @@ fun HeadTrackingScreen() {
|
|||||||
title = stringResource(R.string.head_tracking),
|
title = stringResource(R.string.head_tracking),
|
||||||
actionButtons = listOf(
|
actionButtons = listOf(
|
||||||
{ scaffoldBackdrop ->
|
{ scaffoldBackdrop ->
|
||||||
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
|
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
|
if (!state.headTrackingActive) {
|
||||||
ServiceManager.getService()?.startHeadTracking()
|
viewModel.startHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking started")
|
Log.d("HeadTrackingScreen", "Head tracking started")
|
||||||
} else {
|
} else {
|
||||||
ServiceManager.getService()?.stopHeadTracking()
|
viewModel.stopHeadTracking()
|
||||||
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
Log.d("HeadTrackingScreen", "Head tracking stopped")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = if (isActive) "" else "",
|
icon = if (state.headTrackingActive) "" else "",
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, _ ->
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
var gestureText by remember { mutableStateOf("") }
|
var gestureText by remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -166,35 +165,49 @@ fun HeadTrackingScreen() {
|
|||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = "Head Gestures",
|
label = "Head Gestures",
|
||||||
sharedPreferences = sharedPreferences,
|
checked = state.headGesturesEnabled,
|
||||||
sharedPreferenceKey = "head_gestures",
|
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||||
)
|
enabled = state.isPremium,
|
||||||
|
description = stringResource(R.string.head_gestures_details)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.head_gestures_details),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(0.6f)
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Head Orientation",
|
"Head Orientation",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
HeadVisualization()
|
HeadVisualization()
|
||||||
|
|
||||||
@@ -202,12 +215,12 @@ fun HeadTrackingScreen() {
|
|||||||
Text(
|
Text(
|
||||||
"Velocity",
|
"Velocity",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
color = textColor
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
AccelerationPlot()
|
AccelerationPlot()
|
||||||
|
|
||||||
@@ -383,7 +396,8 @@ private fun HeadVisualization() {
|
|||||||
.aspectRatio(2f),
|
.aspectRatio(2f),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = backgroundColor
|
containerColor = backgroundColor
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -458,9 +472,9 @@ private fun HeadVisualization() {
|
|||||||
spherePath.close()
|
spherePath.close()
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.3f,
|
center.x + sinY * faceRadius * 0.3f,
|
||||||
center.y - sinP * faceRadius * 0.3f,
|
center.y - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 1.4f,
|
faceRadius * 1.4f,
|
||||||
@@ -472,14 +486,14 @@ private fun HeadVisualization() {
|
|||||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
backgroundColor.copy(alpha = 0.7f).toArgb()
|
||||||
),
|
),
|
||||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), paint)
|
drawPath(spherePath.asAndroidPath(), paint)
|
||||||
|
|
||||||
val highlightPaint = android.graphics.Paint().apply {
|
val highlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
||||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 0.9f,
|
faceRadius * 0.9f,
|
||||||
@@ -489,15 +503,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 0.3f, 1f),
|
floatArrayOf(0f, 0.3f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 30 else 60
|
alpha = if (darkTheme) 30 else 60
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
||||||
|
|
||||||
val secondaryHighlightPaint = android.graphics.Paint().apply {
|
val secondaryHighlightPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
||||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
||||||
faceRadius * 0.7f,
|
faceRadius * 0.7f,
|
||||||
@@ -506,15 +520,15 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.TRANSPARENT
|
android.graphics.Color.TRANSPARENT
|
||||||
),
|
),
|
||||||
floatArrayOf(0f, 1f),
|
floatArrayOf(0f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 15 else 30
|
alpha = if (darkTheme) 15 else 30
|
||||||
}
|
}
|
||||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
||||||
|
|
||||||
val shadowPaint = android.graphics.Paint().apply {
|
val shadowPaint = Paint().apply {
|
||||||
style = android.graphics.Paint.Style.FILL
|
style = Paint.Style.FILL
|
||||||
shader = android.graphics.RadialGradient(
|
shader = RadialGradient(
|
||||||
center.x + sinY * faceRadius * 0.5f,
|
center.x + sinY * faceRadius * 0.5f,
|
||||||
center.y - sinP * faceRadius * 0.5f,
|
center.y - sinP * faceRadius * 0.5f,
|
||||||
faceRadius * 1.1f,
|
faceRadius * 1.1f,
|
||||||
@@ -523,7 +537,7 @@ private fun HeadVisualization() {
|
|||||||
android.graphics.Color.BLACK
|
android.graphics.Color.BLACK
|
||||||
),
|
),
|
||||||
floatArrayOf(0.7f, 1f),
|
floatArrayOf(0.7f, 1f),
|
||||||
android.graphics.Shader.TileMode.CLAMP
|
Shader.TileMode.CLAMP
|
||||||
)
|
)
|
||||||
alpha = if (darkTheme) 40 else 20
|
alpha = if (darkTheme) 40 else 20
|
||||||
}
|
}
|
||||||
@@ -583,13 +597,13 @@ private fun HeadVisualization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
typeface = android.graphics.Typeface.create(
|
typeface = Typeface.create(
|
||||||
"SF Pro",
|
"SF Pro",
|
||||||
android.graphics.Typeface.NORMAL
|
Typeface.NORMAL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +648,8 @@ private fun AccelerationPlot() {
|
|||||||
.height(300.dp),
|
.height(300.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
||||||
)
|
),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -702,10 +717,10 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.RIGHT
|
textAlign = Paint.Align.RIGHT
|
||||||
}
|
}
|
||||||
|
|
||||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||||
@@ -718,20 +733,20 @@ private fun AccelerationPlot() {
|
|||||||
|
|
||||||
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
drawContext.canvas.nativeCanvas.apply {
|
||||||
val paint = android.graphics.Paint().apply {
|
val paint = Paint().apply {
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||||
textSize = 12.sp.toPx()
|
textSize = 12.sp.toPx()
|
||||||
textAlign = android.graphics.Paint.Align.LEFT
|
textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||||
}
|
}
|
||||||
@@ -739,11 +754,3 @@ private fun AccelerationPlot() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun HeadTrackingScreenPreview() {
|
|
||||||
HeadTrackingScreen()
|
|
||||||
}
|
|
||||||
+23
-51
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
@@ -50,15 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||||
isSystemInDarkTheme()
|
isSystemInDarkTheme()
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.adjustments)
|
title = stringResource(R.string.adjustments)
|
||||||
@@ -125,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
val hearingAidATTListener = remember {
|
||||||
object : (ByteArray) -> Unit {
|
object : (ByteArray) -> Unit {
|
||||||
override fun invoke(value: ByteArray) {
|
override fun invoke(value: ByteArray) {
|
||||||
@@ -165,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
@@ -256,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -268,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||||
description = stringResource(R.string.swipe_amplification_description)
|
description = stringResource(R.string.swipe_amplification_description)
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -288,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -300,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -311,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description)
|
||||||
)
|
)
|
||||||
+42
-60
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -61,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "AccessibilitySettings"
|
private const val TAG = "AccessibilitySettings"
|
||||||
@@ -78,23 +78,22 @@ private const val TAG = "AccessibilitySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidScreen(navController: NavController) {
|
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val initialLoad = remember { mutableStateOf(true) }
|
val initialLoad = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val hearingAidEnabled = remember {
|
val hearingAidEnabled = remember {
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
|
||||||
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
|
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||||
}
|
}
|
||||||
|
|
||||||
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
|
||||||
@@ -102,7 +101,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_aid),
|
title = stringResource(R.string.hearing_aid),
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
@@ -113,43 +112,18 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
hazeStateS.value = hazeState
|
hazeStateS.value = hazeState
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val hearingAidListener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
|
|
||||||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
|
|
||||||
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
|
|
||||||
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
|
|
||||||
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
|
||||||
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hearingAidEnabled.value) {
|
LaunchedEffect(hearingAidEnabled.value) {
|
||||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||||
hearingAidEnabled.value = false
|
hearingAidEnabled.value = false
|
||||||
}
|
}
|
||||||
initialLoad.value = false
|
initialLoad.value = false
|
||||||
@@ -186,7 +160,8 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.hearing_aid),
|
label = stringResource(R.string.hearing_aid),
|
||||||
checkedState = hearingAidEnabled,
|
checked = hearingAidEnabled.value,
|
||||||
|
onCheckedChange = { hearingAidEnabled.value = it },
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
@@ -198,7 +173,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "hearing_aid_adjustments",
|
to = "hearing_aid_adjustments",
|
||||||
name = stringResource(R.string.adjustments),
|
name = stringResource(R.string.adjustments),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = false
|
independent = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +192,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
NavigationButton(
|
NavigationButton(
|
||||||
to = "update_hearing_test",
|
to = "update_hearing_test",
|
||||||
name = stringResource(R.string.update_hearing_test),
|
name = stringResource(R.string.update_hearing_test),
|
||||||
navController,
|
navController = navController,
|
||||||
independent = true
|
independent = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,9 +233,9 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
// independent = false
|
// independent = false
|
||||||
// )
|
// )
|
||||||
// }
|
// }
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
showDialog = showDialog,
|
showDialog = showDialog,
|
||||||
title = "Enable Hearing Aid",
|
title = "Enable Hearing Aid",
|
||||||
@@ -269,26 +244,33 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
dismissText = "Cancel",
|
dismissText = "Cancel",
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
|
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
|
||||||
if (!enrolled) {
|
if (!enrolled) {
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
} else {
|
} else {
|
||||||
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
|
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||||
}
|
}
|
||||||
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
|
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
|
||||||
hearingAidEnabled.value = true
|
hearingAidEnabled.value = true
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
if (state.hearingAidData.isEmpty()) {
|
||||||
val parsed = parseTransparencySettingsResponse(data)
|
Log.w(TAG, "read failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(attManager, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hazeState = hazeStateS.value,
|
onDismiss = {
|
||||||
// backdrop = backdrop
|
hearingAidEnabled.value = false
|
||||||
|
showDialog.value = false
|
||||||
|
},
|
||||||
|
// hazeState = hazeStateS.value,
|
||||||
|
backdrop = backdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.hearing_protection),
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
if (!state.isPremium) {
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.vendorIdHook) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.environmental_noise),
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setATTCharacteristicValue(
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.workspace_use),
|
||||||
|
label = stringResource(R.string.ppe),
|
||||||
|
description = stringResource(R.string.workspace_use_description),
|
||||||
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 1,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommandBoolean(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-15
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: Job? = null
|
||||||
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = LocalContext.current
|
||||||
val libraries by produceLibraries {
|
val libraries by produceLibraries {
|
||||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||||
.bufferedReader()
|
.bufferedReader()
|
||||||
+62
-70
@@ -18,41 +18,29 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
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.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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
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.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.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
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
|
||||||
@@ -66,54 +54,34 @@ 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.composables.SelectItem
|
import me.kavishdevar.librepods.presentation.components.SelectItem
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
import me.kavishdevar.librepods.presentation.components.StyledSelectList
|
||||||
import me.kavishdevar.librepods.constants.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDivider() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 72.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RightDividerNoIcon() {
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 20.dp, end = 20.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val state by viewModel.uiState.collectAsState()
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
|
||||||
|
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
|
||||||
if (modesByte != null) {
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
|
||||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
|
||||||
}
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||||
@@ -124,9 +92,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = name
|
title = name
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
@@ -148,11 +115,36 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||||
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
|
||||||
}
|
},
|
||||||
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
StyledSelectList(items = actionItems)
|
StyledSelectList(items = actionItems)
|
||||||
|
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate("purchase_screen")
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -161,6 +153,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f),
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -176,10 +169,11 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
val allowOff = offListeningModeValue == 1.toByte()
|
val allowOff = offListeningModeValue == 1.toByte()
|
||||||
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
|
||||||
|
|
||||||
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
?.get(0)?.toInt()
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
?: sharedPreferences.getInt("long_press_byte", 0b0101)
|
||||||
var currentByte by remember { mutableStateOf(initialByte) }
|
|
||||||
|
var currentByte by remember { mutableIntStateOf(initialByte) }
|
||||||
|
|
||||||
val listeningModeItems = mutableListOf<SelectItem>()
|
val listeningModeItems = mutableListOf<SelectItem>()
|
||||||
if (allowOff) {
|
if (allowOff) {
|
||||||
@@ -197,8 +191,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -223,8 +217,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -246,8 +240,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -269,8 +263,8 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
} else {
|
} else {
|
||||||
currentByte or bit
|
currentByte or bit
|
||||||
}
|
}
|
||||||
ServiceManager.getService()!!.aacpManager.sendControlCommand(
|
viewModel.setControlCommandByte(
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
newValue.toByte()
|
newValue.toByte()
|
||||||
)
|
)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -296,9 +290,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun countEnabledModes(byteValue: Int): Int {
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
+528
@@ -0,0 +1,528 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PurchaseScreen(
|
||||||
|
viewModel: PurchaseViewModel = viewModel(),
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.unlock_advanced_features)
|
||||||
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||||
|
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
LaunchedEffect(state.isPremium) {
|
||||||
|
if (state.isPremium) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.isPremium) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.free_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ear_detection),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ear_detection_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.battery),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.battery_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.noise_control),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.noise_control_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (BuildConfig.FLAVOR == "xposed") {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
|
||||||
|
R.string.requires_xposed
|
||||||
|
) + ")",
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_features),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.conversational_awareness_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.digital_assistant_on_long_press),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.head_gestures),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.head_gestures_details),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_device_settings),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.advanced_device_settings_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.automatic_connection),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.automatic_connection_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.customizations_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_the_development),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.support_development_description),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = textColor.copy(0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.feature_availability_disclaimer),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Light,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.purchase(context)
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
|
||||||
|
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.buy_price, state.price),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = Color.White
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.restorePurchases()
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
isInteractive = false
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.restore_purchases),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
color = textColor
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun RenameScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
keyboardController?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.name),
|
||||||
|
) { spacerHeight ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
|
|
||||||
|
val textFieldState = rememberTextFieldState()
|
||||||
|
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
|
||||||
|
LaunchedEffect(textFieldState.text) {
|
||||||
|
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||||
|
viewModel.setName(textFieldState.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
textFieldState,
|
||||||
|
focusRequester
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-72
@@ -16,8 +16,9 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -41,8 +42,9 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -58,23 +60,20 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.data.TransparencySettings
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import me.kavishdevar.librepods.utils.TransparencySettings
|
|
||||||
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
|
|
||||||
import me.kavishdevar.librepods.utils.sendTransparencySettings
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@@ -84,14 +83,10 @@ private const val TAG = "TransparencySettings"
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TransparencySettingsScreen(navController: NavController) {
|
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
|
||||||
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
|
|
||||||
val isSdpOffsetAvailable =
|
|
||||||
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
|
|
||||||
|
|
||||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||||
@@ -99,9 +94,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
|
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_transparency_mode)
|
title = stringResource(R.string.customize_transparency_mode)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -111,7 +109,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = remember { mutableStateOf(false) }
|
||||||
@@ -148,23 +146,6 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparencyListener = remember {
|
|
||||||
object : (ByteArray) -> Unit {
|
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseTransparencySettingsResponse(value)
|
|
||||||
enabled.value = parsed.enabled
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue =
|
|
||||||
parsed.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
|
||||||
eq.value = parsed.leftEQ.copyOf()
|
|
||||||
Log.d(TAG, "Updated transparency settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
enabled.value,
|
enabled.value,
|
||||||
amplificationSliderValue.floatValue,
|
amplificationSliderValue.floatValue,
|
||||||
@@ -205,35 +186,22 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
balance = balanceSliderValue.floatValue
|
balance = balanceSliderValue.floatValue
|
||||||
)
|
)
|
||||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||||
sendTransparencySettings(attManager, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
Log.d(TAG, "Connecting to ATT...")
|
||||||
try {
|
try {
|
||||||
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
|
|
||||||
|
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
||||||
try {
|
try {
|
||||||
if (aacpManager != null) {
|
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
val aacpEQ = state.eqData
|
||||||
val aacpEQ = aacpManager.eqData
|
if (aacpEQ.isNotEmpty()) {
|
||||||
if (aacpEQ.isNotEmpty()) {
|
eq.value = aacpEQ.copyOf()
|
||||||
eq.value = aacpEQ.copyOf()
|
phoneMediaEQ.value = aacpEQ.copyOf()
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "No AACPManager available")
|
Log.d(TAG, "AACPManager EQ data empty")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
||||||
@@ -243,7 +211,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
for (attempt in 1..3) {
|
for (attempt in 1..3) {
|
||||||
initialReadAttempts.intValue = attempt
|
initialReadAttempts.intValue = attempt
|
||||||
try {
|
try {
|
||||||
val data = attManager.read(ATTHandles.TRANSPARENCY)
|
val data = state.transparencyData
|
||||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
parsedSettings = parseTransparencySettingsResponse(data = data)
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
Log.d(TAG, "Parsed settings on attempt $attempt")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -276,19 +244,19 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only show transparency mode section if SDP offset is available
|
if (state.vendorIdHook) {
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.transparency_mode),
|
label = stringResource(R.string.transparency_mode),
|
||||||
checkedState = enabled,
|
checked = enabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.customize_transparency_mode_description)
|
description = stringResource(R.string.customize_transparency_mode_description),
|
||||||
|
onCheckedChange = { enabled.value = it }
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = amplificationSliderValue,
|
value = amplificationSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
amplificationSliderValue.floatValue = it
|
amplificationSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -300,7 +268,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = balanceSliderValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -313,7 +281,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
mutableFloatState = toneSliderValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -325,7 +293,7 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = 0f..1f,
|
valueRange = 0f..1f,
|
||||||
mutableFloatState = ambientNoiseReductionSliderValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
@@ -336,14 +304,12 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checkedState = conversationBoostEnabled,
|
checked = conversationBoostEnabled.value,
|
||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description),
|
||||||
|
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Only show transparency mode EQ section if SDP offset is available
|
|
||||||
if (isSdpOffsetAvailable.value) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.equalizer),
|
text = stringResource(R.string.equalizer),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -443,6 +409,8 @@ fun TransparencySettingsScreen(navController: NavController) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
import me.kavishdevar.librepods.utils.LogCollector
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -216,7 +216,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.troubleshooting)
|
title = stringResource(R.string.troubleshooting)
|
||||||
){ spacerHeight, hazeState ->
|
){ topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -225,7 +225,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.saved_logs),
|
text = stringResource(R.string.saved_logs),
|
||||||
+60
-49
@@ -16,9 +16,8 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -33,7 +32,6 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -55,31 +53,25 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.utils.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.utils.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
fun UpdateHearingTestScreen() {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val attManager = ServiceManager.getService()?.attManager
|
||||||
if (attManager == null) {
|
if (attManager == null) {
|
||||||
@@ -96,7 +88,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
) { spacerHeight, hazeState ->
|
) { topPadding, hazeState, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -108,7 +100,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
) {
|
) {
|
||||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_test_value_instruction),
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
@@ -138,17 +130,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = leftAmplification.value,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = rightAmplification.value,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = tone.value,
|
leftTone = tone.floatValue,
|
||||||
rightTone = tone.value,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.value
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,11 +153,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
tone.value = parsed.leftTone
|
tone.floatValue = parsed.leftTone
|
||||||
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
|
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||||
ownVoiceAmplification.value = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
leftAmplification.value = parsed.leftAmplification
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
rightAmplification.value = parsed.rightAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
@@ -181,31 +173,45 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
|
LaunchedEffect(
|
||||||
|
leftEQ.value,
|
||||||
|
rightEQ.value,
|
||||||
|
conversationBoostEnabled.value,
|
||||||
|
initialLoadComplete.value,
|
||||||
|
initialReadSucceeded.value,
|
||||||
|
leftAmplification.floatValue,
|
||||||
|
rightAmplification.floatValue,
|
||||||
|
tone.floatValue,
|
||||||
|
ambientNoiseReduction.floatValue,
|
||||||
|
ownVoiceAmplification.floatValue
|
||||||
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialLoadComplete.value) {
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
Log.d(TAG, "Initial device load not complete - skipping send")
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
if (!initialReadSucceeded.value) {
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Initial device read not successful yet - skipping send until read succeeds"
|
||||||
|
)
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = leftAmplification.value,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
rightAmplification = rightAmplification.value,
|
rightAmplification = rightAmplification.floatValue,
|
||||||
leftTone = tone.value,
|
leftTone = tone.floatValue,
|
||||||
rightTone = tone.value,
|
rightTone = tone.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReduction.value,
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReduction.value,
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
netAmplification = leftAmplification.value + rightAmplification.value / 2,
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.value
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||||
@@ -240,14 +246,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
tone.value = parsedSettings.leftTone
|
tone.floatValue = parsedSettings.leftTone
|
||||||
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
|
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
||||||
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
||||||
leftAmplification.value = parsedSettings.leftAmplification
|
leftAmplification.floatValue = parsedSettings.leftAmplification
|
||||||
rightAmplification.value = parsedSettings.rightAmplification
|
rightAmplification.floatValue = parsedSettings.rightAmplification
|
||||||
initialReadSucceeded.value = true
|
initialReadSucceeded.value = true
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -256,7 +265,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
val frequencies =
|
||||||
|
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -342,6 +352,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+15
-27
@@ -16,25 +16,25 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -45,36 +45,23 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VersionScreen(navController: NavController) {
|
fun VersionScreen(viewModel: AirPodsViewModel) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
val airpodsInstance = service.airpodsInstance
|
|
||||||
if (airpodsInstance == null) return
|
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
title = stringResource(R.string.version)
|
||||||
) { spacerHeight ->
|
) { spacerHeight ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -93,7 +80,8 @@ fun VersionScreen(navController: NavController) {
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor.copy(alpha = 0.6f)
|
color = textColor.copy(alpha = 0.6f),
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,7 +108,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version1 ?: "N/A",
|
text = state.version1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -149,7 +137,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version2 ?: "N/A",
|
text = state.version2,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
@@ -178,7 +166,7 @@ fun VersionScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = airpodsInstance.version3 ?: "N/A",
|
text = state.version3,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
color = textColor.copy(0.8f),
|
color = textColor.copy(0.8f),
|
||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package me.kavishdevar.librepods.ui.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
+544
@@ -0,0 +1,544 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsModels
|
||||||
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
|
import me.kavishdevar.librepods.data.Battery
|
||||||
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
|
||||||
|
@Suppress("ArrayInDataClass")
|
||||||
|
data class AirPodsUiState(
|
||||||
|
val deviceName: String,
|
||||||
|
|
||||||
|
val isLocallyConnected: Boolean = false,
|
||||||
|
|
||||||
|
val instance: AirPodsInstance? = null,
|
||||||
|
val capabilities: Set<Capability> = emptySet(),
|
||||||
|
|
||||||
|
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
|
||||||
|
val offListeningMode: Boolean = true,
|
||||||
|
|
||||||
|
val battery: List<Battery> = emptyList(),
|
||||||
|
val ancMode: Int = 3,
|
||||||
|
|
||||||
|
val modelName: String = "",
|
||||||
|
val actualModel: String = "",
|
||||||
|
val serialNumbers: List<String> = emptyList(),
|
||||||
|
val version1: String = "",
|
||||||
|
val version2: String = "",
|
||||||
|
val version3: String = "",
|
||||||
|
|
||||||
|
val headTrackingActive: Boolean = false,
|
||||||
|
val headGesturesEnabled: Boolean = true,
|
||||||
|
|
||||||
|
val eqData: FloatArray = floatArrayOf(),
|
||||||
|
|
||||||
|
val automaticEarDetectionEnabled: Boolean = true,
|
||||||
|
val automaticConnectionEnabled: Boolean = true,
|
||||||
|
|
||||||
|
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
|
||||||
|
val loudSoundReductionEnabled: Boolean = false,
|
||||||
|
val transparencyData: ByteArray = byteArrayOf(),
|
||||||
|
val hearingAidData: ByteArray = byteArrayOf(),
|
||||||
|
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val vendorIdHook: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class AirPodsViewModel(
|
||||||
|
private val service: AirPodsService,
|
||||||
|
private val sharedPreferences: SharedPreferences,
|
||||||
|
private val controlRepo: ControlCommandRepository,
|
||||||
|
private val appContext: Context
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(
|
||||||
|
AirPodsUiState(
|
||||||
|
deviceName = sharedPreferences.getString(
|
||||||
|
"name",
|
||||||
|
"AirPods Pro"
|
||||||
|
) ?: "AirPods Pro"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val uiState: StateFlow<AirPodsUiState> = _uiState
|
||||||
|
|
||||||
|
private var isDemoMode = false
|
||||||
|
val demoActivated = MutableSharedFlow<Unit>()
|
||||||
|
|
||||||
|
private val listeners =
|
||||||
|
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
|
||||||
|
|
||||||
|
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
|
|
||||||
|
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||||
|
|
||||||
|
private val _cameraAction = MutableStateFlow(
|
||||||
|
sharedPreferences.getString("camera_action", null)
|
||||||
|
?.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
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeBroadcasts()
|
||||||
|
loadName()
|
||||||
|
loadInstance()
|
||||||
|
loadSharedPreferences()
|
||||||
|
setupControlObservers()
|
||||||
|
observeBilling()
|
||||||
|
loadControlList()
|
||||||
|
observeATT()
|
||||||
|
if (isDemoMode) activateDemoMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
listeners.forEach { (id, listener) ->
|
||||||
|
controlRepo.remove(id, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
appContext.unregisterReceiver(broadcastReceiver)
|
||||||
|
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadName() {
|
||||||
|
val name = sharedPreferences.getString("name", "AirPods Pro")!!
|
||||||
|
_uiState.update { it.copy(deviceName = name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
if (isDemoMode) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
// if (!billingFirstCollectDone) {
|
||||||
|
// billingFirstCollectDone = true
|
||||||
|
// return@collect
|
||||||
|
// }
|
||||||
|
if (!premium) {
|
||||||
|
setControlCommandBoolean(
|
||||||
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
setHeadGesturesEnabled(false)
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBroadcasts() {
|
||||||
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
if (!isDemoMode) when (action) {
|
||||||
|
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLocallyConnected = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLocallyConnected = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.BATTERY_DATA -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(battery = service.getBattery())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.EQ_DATA -> {
|
||||||
|
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(eqData = data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
|
||||||
|
loadInstance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||||
|
addAction(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
addAction(AirPodsNotifications.EQ_DATA)
|
||||||
|
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
appContext.registerReceiver(
|
||||||
|
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandValue(
|
||||||
|
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||||
|
) {
|
||||||
|
if (!isDemoMode) controlRepo.setValue(identifier, value)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
controlStates = it.controlStates + (identifier to value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandBoolean(
|
||||||
|
identifier: ControlCommandIdentifiers, enabled: Boolean
|
||||||
|
) {
|
||||||
|
setControlCommandValue(
|
||||||
|
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandInt(
|
||||||
|
identifier: ControlCommandIdentifiers, value: Int
|
||||||
|
) {
|
||||||
|
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setControlCommandByte(
|
||||||
|
identifier: ControlCommandIdentifiers, value: Byte
|
||||||
|
) {
|
||||||
|
setControlCommandValue(identifier, byteArrayOf(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeControl(identifier: ControlCommandIdentifiers) {
|
||||||
|
val listener = controlRepo.observe(identifier) { value ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
val current = state.controlStates[identifier]
|
||||||
|
if (current?.contentEquals(value) == true) return@update state
|
||||||
|
|
||||||
|
state.copy(
|
||||||
|
controlStates = state.controlStates + (identifier to value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners[identifier] = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'm lazy, sorry.
|
||||||
|
fun setupControlObservers() {
|
||||||
|
val identifiersList = listOf(
|
||||||
|
ControlCommandIdentifiers.MIC_MODE,
|
||||||
|
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||||
|
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
||||||
|
ControlCommandIdentifiers.LISTENING_MODE,
|
||||||
|
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
|
||||||
|
ControlCommandIdentifiers.CHIME_VOLUME,
|
||||||
|
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||||
|
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||||
|
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
||||||
|
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||||
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
|
ControlCommandIdentifiers.HEARING_AID,
|
||||||
|
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||||
|
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
||||||
|
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
|
||||||
|
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||||
|
ControlCommandIdentifiers.STEM_CONFIG,
|
||||||
|
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
|
||||||
|
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||||
|
ControlCommandIdentifiers.OWNS_CONNECTION,
|
||||||
|
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
|
||||||
|
)
|
||||||
|
for (identifier in identifiersList) {
|
||||||
|
observeControl(identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshInitialData() {
|
||||||
|
if (isDemoMode) return
|
||||||
|
service.let { service ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLocallyConnected = service.isConnected(), battery = service.getBattery()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSharedPreferences() {
|
||||||
|
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
|
||||||
|
val automaticEarDetectionEnabled =
|
||||||
|
sharedPreferences.getBoolean("automatic_ear_detection", true)
|
||||||
|
val automaticConnectionEnabled =
|
||||||
|
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
|
||||||
|
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
|
||||||
|
val leftAction = StemAction.valueOf(
|
||||||
|
sharedPreferences.getString(
|
||||||
|
"left_long_press_action",
|
||||||
|
"CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
)
|
||||||
|
val rightAction = StemAction.valueOf(
|
||||||
|
sharedPreferences.getString(
|
||||||
|
"right_long_press_action",
|
||||||
|
"CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||||
|
)
|
||||||
|
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
offListeningMode = offListeningModeEnabled,
|
||||||
|
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
|
||||||
|
automaticConnectionEnabled = automaticConnectionEnabled,
|
||||||
|
headGesturesEnabled = headGesturesEnabled,
|
||||||
|
leftAction = leftAction,
|
||||||
|
rightAction = rightAction,
|
||||||
|
vendorIdHook = vendorIdHook
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOffListeningMode(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(offListeningMode = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(headGesturesEnabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadControlList() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
controlStates = controlRepo.getMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInstance() {
|
||||||
|
val instance = service.airpodsInstance ?: AirPodsInstance(
|
||||||
|
name = "AirPods",
|
||||||
|
model = AirPodsModels.getModelByModelNumber("A3049")!!,
|
||||||
|
actualModelNumber = "A3049",
|
||||||
|
serialNumber = null,
|
||||||
|
leftSerialNumber = null,
|
||||||
|
rightSerialNumber = null,
|
||||||
|
version1 = null,
|
||||||
|
version2 = null,
|
||||||
|
version3 = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
capabilities = instance.model.capabilities,
|
||||||
|
instance = instance,
|
||||||
|
modelName = instance.model.displayName,
|
||||||
|
actualModel = instance.actualModelNumber,
|
||||||
|
serialNumbers = listOf(
|
||||||
|
instance.serialNumber ?: "",
|
||||||
|
instance.leftSerialNumber ?: "",
|
||||||
|
instance.rightSerialNumber ?: ""
|
||||||
|
),
|
||||||
|
version1 = instance.version1 ?: "",
|
||||||
|
version2 = instance.version2 ?: "",
|
||||||
|
version3 = instance.version3 ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnectFromSavedMac() {
|
||||||
|
service.reconnectFromSavedMac()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setName(name: String) {
|
||||||
|
service.setName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHeadTracking() {
|
||||||
|
service.startHeadTracking()
|
||||||
|
_uiState.update { it.copy(headTrackingActive = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopHeadTracking() {
|
||||||
|
service.stopHeadTracking()
|
||||||
|
_uiState.update { it.copy(headTrackingActive = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
|
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
||||||
|
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
service.attManager?.connect()
|
||||||
|
while (service.attManager?.socket?.isConnected != true) {
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
service.attManager?.write(handle, value)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshATT() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val loudSoundReduction =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
||||||
|
val transparencyData =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
||||||
|
val hearingAid =
|
||||||
|
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
||||||
|
transparencyData = transparencyData,
|
||||||
|
hearingAidData = hearingAid
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeATT() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
service.attManager?.connect()
|
||||||
|
while (service.attManager?.socket?.isConnected != true) {
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
||||||
|
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
refreshATT()
|
||||||
|
delay(15000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
automaticEarDetectionEnabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutomaticConnectionEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
|
||||||
|
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
automaticConnectionEnabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun activateDemoMode() {
|
||||||
|
isDemoMode = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
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) {
|
||||||
|
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
service.disconnectAirPods()
|
||||||
|
}
|
||||||
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
|
import me.kavishdevar.librepods.utils.NativeBridge
|
||||||
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
data class AppSettingsUiState(
|
||||||
|
val showPhoneBatteryInWidget: Boolean = false,
|
||||||
|
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
|
||||||
|
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
|
||||||
|
val disconnectWhenNotWearing: Boolean = false,
|
||||||
|
val takeoverWhenDisconnected: Boolean = false,
|
||||||
|
val takeoverWhenIdle: Boolean = false,
|
||||||
|
val takeoverWhenMusic: Boolean = false,
|
||||||
|
val takeoverWhenCall: Boolean = false,
|
||||||
|
val takeoverWhenRingingCall: Boolean = false,
|
||||||
|
val takeoverWhenMediaStart: Boolean = false,
|
||||||
|
val useAlternateHeadTrackingPackets: Boolean = true,
|
||||||
|
val conversationalAwarenessVolume: Float = 43f,
|
||||||
|
val showCameraDialog: Boolean = false,
|
||||||
|
val cameraPackageValue: String = "",
|
||||||
|
val cameraPackageError: String? = null,
|
||||||
|
val vendorIdHook: Boolean = false,
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val connectionSuccessful: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
|
|
||||||
|
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
|
||||||
|
if (key == "connection_successful") {
|
||||||
|
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadSettings()
|
||||||
|
observeBilling()
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettings() {
|
||||||
|
_uiState.update { currentState ->
|
||||||
|
currentState.copy(
|
||||||
|
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||||
|
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
|
||||||
|
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
|
||||||
|
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
|
||||||
|
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
|
||||||
|
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
|
||||||
|
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
|
||||||
|
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
|
||||||
|
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
|
||||||
|
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
|
||||||
|
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
|
||||||
|
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
|
||||||
|
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
|
||||||
|
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
|
||||||
|
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
|
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
|
||||||
|
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
|
||||||
|
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
|
||||||
|
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisconnectWhenNotWearing(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
|
||||||
|
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenDisconnected(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenIdle(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenMusic(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenCall(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenCall = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenRingingCall(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTakeoverWhenMediaStart(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
|
||||||
|
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
|
||||||
|
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
|
||||||
|
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConversationalAwarenessVolume(volume: Float) {
|
||||||
|
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
|
||||||
|
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowCameraDialog(show: Boolean) {
|
||||||
|
_uiState.update { it.copy(showCameraDialog = show) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraPackageValue(value: String) {
|
||||||
|
_uiState.update { it.copy(cameraPackageValue = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraPackageError(error: String?) {
|
||||||
|
_uiState.update { it.copy(cameraPackageError = error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCameraPackage() {
|
||||||
|
if (_uiState.value.cameraPackageValue.isBlank()) {
|
||||||
|
sharedPreferences.edit { remove("custom_camera_package") }
|
||||||
|
} else {
|
||||||
|
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
|
||||||
|
}
|
||||||
|
setShowCameraDialog(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVendorIdHook(enabled: Boolean) {
|
||||||
|
NativeBridge.setSdpHook(enabled)
|
||||||
|
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
|
||||||
|
_uiState.update { it.copy(vendorIdHook = enabled) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.viewmodel
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
|
|
||||||
|
data class PurchaseUiState(
|
||||||
|
val isPremium: Boolean = false,
|
||||||
|
val price: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
class PurchaseViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private val _uiState = MutableStateFlow(PurchaseUiState())
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeBilling()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeBilling() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
|
_uiState.update { it.copy(isPremium = premium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
BillingManager.provider.price.collect { price ->
|
||||||
|
_uiState.update { it.copy(price = price) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun purchase(context: Context) {
|
||||||
|
BillingManager.provider.purchase(context as Activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restorePurchases() {
|
||||||
|
BillingManager.provider.restorePurchases()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.presentation.widgets
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.presentation.widgets
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
@@ -29,7 +29,7 @@ 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.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AdaptiveStrengthScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
|
||||||
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
|
||||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
|
||||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
|
||||||
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val listener = remember {
|
|
||||||
object : AACPManager.ControlCommandListener {
|
|
||||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
|
||||||
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
|
|
||||||
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
|
|
||||||
sliderValue.floatValue = (100 - it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
service.aacpManager.registerControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
onDispose {
|
|
||||||
service.aacpManager.unregisterControlCommandListener(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
|
||||||
listener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.customize_adaptive_audio)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.customize_adaptive_audio),
|
|
||||||
mutableFloatState = sliderValue,
|
|
||||||
onValueChange = {
|
|
||||||
sliderValue.floatValue = it
|
|
||||||
debounceJob?.cancel()
|
|
||||||
debounceJob = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
delay(300)
|
|
||||||
service.aacpManager.sendControlCommand(
|
|
||||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
|
|
||||||
(100 - it).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(0f, 50f, 100f),
|
|
||||||
startIcon = "",
|
|
||||||
endIcon = "",
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.adaptive_audio_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import android.content.Context.RECEIVER_EXPORTED
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import com.kyant.backdrop.drawBackdrop
|
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.AboutCard
|
|
||||||
import me.kavishdevar.librepods.composables.AudioSettings
|
|
||||||
import me.kavishdevar.librepods.composables.BatteryView
|
|
||||||
import me.kavishdevar.librepods.composables.CallControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
|
||||||
import me.kavishdevar.librepods.composables.ConnectionSettings
|
|
||||||
import me.kavishdevar.librepods.composables.HearingHealthSettings
|
|
||||||
import me.kavishdevar.librepods.composables.MicrophoneSettings
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
|
||||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
|
||||||
import me.kavishdevar.librepods.composables.StyledButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.Capability
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|
||||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
|
||||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
|
||||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
|
||||||
var device by remember { mutableStateOf(dev) }
|
|
||||||
var deviceName by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
TextFieldValue(
|
|
||||||
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
isLocallyConnected = service.isConnectedLocally
|
|
||||||
}
|
|
||||||
|
|
||||||
val nameChangeListener = remember {
|
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
|
||||||
if (key == "name") {
|
|
||||||
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
fun handleRemoteConnection(connected: Boolean) {
|
|
||||||
isRemotelyConnected = connected
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val connectionReceiver = remember {
|
|
||||||
object : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
handleRemoteConnection(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isLocallyConnected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
|
|
||||||
try {
|
|
||||||
context?.unregisterReceiver(this)
|
|
||||||
} catch (e: IllegalArgumentException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
|
|
||||||
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
|
||||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
|
||||||
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
|
|
||||||
} else {
|
|
||||||
context.registerReceiver(connectionReceiver, filter)
|
|
||||||
}
|
|
||||||
onDispose {
|
|
||||||
try {
|
|
||||||
context.unregisterReceiver(connectionReceiver)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(service) {
|
|
||||||
service.let {
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
|
||||||
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
|
|
||||||
})
|
|
||||||
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
|
|
||||||
putExtra("data", it.getANC())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val darkMode = isSystemInDarkTheme()
|
|
||||||
val hazeStateS = remember { mutableStateOf(HazeState()) }
|
|
||||||
|
|
||||||
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
|
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = deviceName.text,
|
|
||||||
actionButtons = listOf(
|
|
||||||
{scaffoldBackdrop ->
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = { navController.navigate("app_settings") },
|
|
||||||
icon = "",
|
|
||||||
darkMode = darkMode,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
snackbarHostState = snackbarHostState
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
hazeStateS.value = hazeState
|
|
||||||
if (isLocallyConnected || isRemotelyConnected) {
|
|
||||||
val instance = service.airpodsInstance
|
|
||||||
if (instance == null) {
|
|
||||||
Text("Error: AirPods instance is null")
|
|
||||||
return@StyledScaffold
|
|
||||||
}
|
|
||||||
val capabilities = instance.model.capabilities
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
|
||||||
item(key = "battery") {
|
|
||||||
BatteryView(service = service)
|
|
||||||
}
|
|
||||||
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
|
|
||||||
item(key = "name") {
|
|
||||||
NavigationButton(
|
|
||||||
to = "rename",
|
|
||||||
name = stringResource(R.string.name),
|
|
||||||
currentState = deviceName.text,
|
|
||||||
navController = navController,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
|
|
||||||
if (actAsAppleDeviceHookEnabled) {
|
|
||||||
item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "hearing_health") {
|
|
||||||
HearingHealthSettings(navController = navController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LISTENING_MODE)) {
|
|
||||||
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "audio") { AudioSettings(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "connection") { ConnectionSettings() }
|
|
||||||
|
|
||||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
|
|
||||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "sleep_detection") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.sleep_detection),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.HEAD_GESTURES)) {
|
|
||||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
|
||||||
|
|
||||||
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
|
|
||||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "off_listening") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.off_listening_mode),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
|
||||||
description = stringResource(R.string.off_listening_mode_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "about") { AboutCard(navController = navController) }
|
|
||||||
|
|
||||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
|
||||||
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.drawBackdrop(
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
exportedBackdrop = backdrop,
|
|
||||||
shape = { RoundedCornerShape(0.dp) },
|
|
||||||
highlight = {
|
|
||||||
Highlight.Ambient.copy(alpha = 0f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 24.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.airpods_not_connected_description),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = { navController.navigate("troubleshooting") },
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.troubleshooting),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
service.reconnectFromSavedMac()
|
|
||||||
},
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reconnect_to_last_device),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConfirmationDialog(
|
|
||||||
showDialog = showDialog,
|
|
||||||
title = stringResource(R.string.support_librepods),
|
|
||||||
message = stringResource(R.string.support_dialog_description),
|
|
||||||
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
|
|
||||||
dismissText = stringResource(R.string.never_show_again),
|
|
||||||
onConfirm = {
|
|
||||||
val browserIntent = Intent(
|
|
||||||
Intent.ACTION_VIEW,
|
|
||||||
"https://github.com/sponsors/kavishdevar".toUri()
|
|
||||||
)
|
|
||||||
context.startActivity(browserIntent)
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
onDismiss = {
|
|
||||||
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
|
|
||||||
},
|
|
||||||
hazeState = hazeStateS.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AirPodsSettingsScreenPreview() {
|
|
||||||
Column (
|
|
||||||
modifier = Modifier.height(2000.dp)
|
|
||||||
) {
|
|
||||||
LibrePodsTheme (
|
|
||||||
darkTheme = true
|
|
||||||
) {
|
|
||||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,981 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun AppSettingsScreen(navController: NavController) {
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
val showResetDialog = remember { mutableStateOf(false) }
|
|
||||||
val showIrkDialog = remember { mutableStateOf(false) }
|
|
||||||
val showEncKeyDialog = remember { mutableStateOf(false) }
|
|
||||||
val showCameraDialog = remember { mutableStateOf(false) }
|
|
||||||
val irkValue = remember { mutableStateOf("") }
|
|
||||||
val encKeyValue = remember { mutableStateOf("") }
|
|
||||||
val cameraPackageValue = remember { mutableStateOf("") }
|
|
||||||
val irkError = remember { mutableStateOf<String?>(null) }
|
|
||||||
val encKeyError = remember { mutableStateOf<String?>(null) }
|
|
||||||
val cameraPackageError = remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
|
||||||
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
|
||||||
val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null)
|
|
||||||
|
|
||||||
if (savedIrk != null) {
|
|
||||||
try {
|
|
||||||
val decoded = Base64.decode(savedIrk)
|
|
||||||
irkValue.value = decoded.joinToString("") { "%02x".format(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
irkValue.value = ""
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedEncKey != null) {
|
|
||||||
try {
|
|
||||||
val decoded = Base64.decode(savedEncKey)
|
|
||||||
encKeyValue.value = decoded.joinToString("") { "%02x".format(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
encKeyValue.value = ""
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (savedCameraPackage != null) {
|
|
||||||
cameraPackageValue.value = savedCameraPackage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val showPhoneBatteryInWidget = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
|
|
||||||
}
|
|
||||||
val conversationalAwarenessPauseMusicEnabled = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
|
|
||||||
}
|
|
||||||
val relativeConversationalAwarenessVolumeEnabled = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
|
|
||||||
}
|
|
||||||
val openDialogForControlling = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
|
|
||||||
}
|
|
||||||
val disconnectWhenNotWearing = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
val takeoverWhenDisconnected = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenIdle = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenMusic = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
|
|
||||||
}
|
|
||||||
val takeoverWhenCall = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
|
|
||||||
}
|
|
||||||
|
|
||||||
val takeoverWhenRingingCall = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
|
|
||||||
}
|
|
||||||
val takeoverWhenMediaStart = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
|
||||||
}
|
|
||||||
|
|
||||||
val useAlternateHeadTrackingPackets = remember {
|
|
||||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateHexInput(input: String): Boolean {
|
|
||||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
|
||||||
return hexPattern.matches(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isProcessingSdp = remember { mutableStateOf(false) }
|
|
||||||
val actAsAppleDevice = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
BackHandler(enabled = isProcessingSdp.value) {}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.app_settings)
|
|
||||||
) { spacerHeight, hazeState ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.widget),
|
|
||||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
|
||||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
|
||||||
checkedState = showPhoneBatteryInWidget,
|
|
||||||
sharedPreferenceKey = "show_phone_battery_in_widget",
|
|
||||||
sharedPreferences = sharedPreferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.conversational_awareness),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
|
|
||||||
conversationalAwarenessPauseMusicEnabled.value = enabled
|
|
||||||
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
|
|
||||||
relativeConversationalAwarenessVolumeEnabled.value = enabled
|
|
||||||
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
|
||||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
|
||||||
checkedState = conversationalAwarenessPauseMusicEnabled,
|
|
||||||
onCheckedChange = { updateConversationalAwarenessPauseMusic(it) },
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
|
||||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
|
||||||
checkedState = relativeConversationalAwarenessVolumeEnabled,
|
|
||||||
onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) },
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) }
|
|
||||||
LaunchedEffect(conversationalAwarenessVolume.floatValue) {
|
|
||||||
sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.conversational_awareness_volume),
|
|
||||||
mutableFloatState = conversationalAwarenessVolume,
|
|
||||||
valueRange = 10f..85f,
|
|
||||||
startLabel = "10%",
|
|
||||||
endLabel = "85%",
|
|
||||||
onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue },
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "",
|
|
||||||
title = stringResource(R.string.camera_control),
|
|
||||||
name = stringResource(R.string.set_custom_camera_package),
|
|
||||||
navController = navController,
|
|
||||||
onClick = { showCameraDialog.value = true },
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.camera_control_app_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.quick_settings_tile),
|
|
||||||
label = stringResource(R.string.open_dialog_for_controlling),
|
|
||||||
description = stringResource(R.string.open_dialog_for_controlling_description),
|
|
||||||
checkedState = openDialogForControlling,
|
|
||||||
onCheckedChange = {
|
|
||||||
openDialogForControlling.value = it
|
|
||||||
sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "activity") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.ear_detection),
|
|
||||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
|
||||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
|
||||||
checkedState = disconnectWhenNotWearing,
|
|
||||||
sharedPreferenceKey = "disconnect_when_not_wearing",
|
|
||||||
sharedPreferences = sharedPreferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.takeover_airpods_state),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_disconnected),
|
|
||||||
description = stringResource(R.string.takeover_disconnected_desc),
|
|
||||||
checkedState = takeoverWhenDisconnected,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenDisconnected.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_idle),
|
|
||||||
description = stringResource(R.string.takeover_idle_desc),
|
|
||||||
checkedState = takeoverWhenIdle,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenIdle.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_idle", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_music),
|
|
||||||
description = stringResource(R.string.takeover_music_desc),
|
|
||||||
checkedState = takeoverWhenMusic,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenMusic.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_music", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_call),
|
|
||||||
description = stringResource(R.string.takeover_call_desc),
|
|
||||||
checkedState = takeoverWhenCall,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenCall.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_call", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.takeover_phone_state),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
){
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_ringing_call),
|
|
||||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
|
||||||
checkedState = takeoverWhenRingingCall,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenRingingCall.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.takeover_media_start),
|
|
||||||
description = stringResource(R.string.takeover_media_start_desc),
|
|
||||||
checkedState = takeoverWhenMediaStart,
|
|
||||||
onCheckedChange = {
|
|
||||||
takeoverWhenMediaStart.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("takeover_when_media_start", it)}
|
|
||||||
},
|
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.advanced_options),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable (
|
|
||||||
onClick = { showIrkDialog.value = true },
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_identity_resolving_key),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_identity_resolving_key_description),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable (
|
|
||||||
onClick = { showEncKeyDialog.value = true },
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_encryption_key),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.set_encryption_key_description),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 16.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
|
||||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
|
||||||
checkedState = useAlternateHeadTrackingPackets,
|
|
||||||
onCheckedChange = {
|
|
||||||
useAlternateHeadTrackingPackets.value = it
|
|
||||||
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)}
|
|
||||||
},
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "troubleshooting",
|
|
||||||
name = stringResource(R.string.troubleshooting),
|
|
||||||
navController = navController,
|
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.troubleshooting_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable()
|
|
||||||
}
|
|
||||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.act_as_an_apple_device),
|
|
||||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
|
||||||
checkedState = actAsAppleDevice,
|
|
||||||
onCheckedChange = {
|
|
||||||
actAsAppleDevice.value = it
|
|
||||||
isProcessingSdp.value = true
|
|
||||||
coroutineScope.launch {
|
|
||||||
if (it) {
|
|
||||||
val radareOffsetFinder = RadareOffsetFinder(context)
|
|
||||||
val success = radareOffsetFinder.findSdpOffset()
|
|
||||||
if (success) {
|
|
||||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RadareOffsetFinder.clearSdpOffset()
|
|
||||||
}
|
|
||||||
isProcessingSdp.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
independent = true,
|
|
||||||
enabled = !isProcessingSdp.value
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showResetDialog.value = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(28.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Refresh,
|
|
||||||
contentDescription = "Reset",
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reset_hook_offset),
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "open_source_licenses",
|
|
||||||
name = stringResource(R.string.open_source_licenses),
|
|
||||||
navController = navController,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
if (showResetDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showResetDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
"Reset Hook Offset",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.reset_hook_offset_description),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.hook_offset_reset_success)
|
|
||||||
val failureText = stringResource(R.string.hook_offset_reset_failure)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (RadareOffsetFinder.clearHookOffsets()) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
successText,
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
|
|
||||||
navController.navigate("onboarding") {
|
|
||||||
popUpTo("settings") { inclusive = true }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
failureText,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
showResetDialog.value = false
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.reset),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showResetDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showIrkDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showIrkDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_identity_resolving_key),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_irk_hex),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = irkValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
|
||||||
irkError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = irkError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (irkError.value != null) {
|
|
||||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.irk_hex_value)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.irk_set_success)
|
|
||||||
val errorText = stringResource(R.string.error_converting_hex)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (!validateHexInput(irkValue.value)) {
|
|
||||||
irkError.value = "Must be exactly 32 hex characters"
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val hexBytes = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = irkValue.value.substring(i * 2, i * 2 + 2)
|
|
||||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
val base64Value = Base64.encode(hexBytes)
|
|
||||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
|
|
||||||
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showIrkDialog.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
irkError.value = errorText + " " + (e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showIrkDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEncKeyDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showEncKeyDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_encryption_key),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_enc_key_hex),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = encKeyValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
|
|
||||||
encKeyError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = encKeyError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (encKeyError.value != null) {
|
|
||||||
Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.enc_key_hex_value)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.encryption_key_set_success)
|
|
||||||
val errorText = stringResource(R.string.error_converting_hex)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (!validateHexInput(encKeyValue.value)) {
|
|
||||||
encKeyError.value = "Must be exactly 32 hex characters"
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val hexBytes = ByteArray(16)
|
|
||||||
for (i in 0 until 16) {
|
|
||||||
val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2)
|
|
||||||
hexBytes[i] = hexByte.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
val base64Value = Base64.encode(hexBytes)
|
|
||||||
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
|
|
||||||
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showEncKeyDialog.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
encKeyError.value = errorText + " " + (e.message ?: "Unknown error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showEncKeyDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCameraDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showCameraDialog.value = false },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.set_custom_camera_package),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.enter_custom_camera_package),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = cameraPackageValue.value,
|
|
||||||
onValueChange = {
|
|
||||||
cameraPackageValue.value = it
|
|
||||||
cameraPackageError.value = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isError = cameraPackageError.value != null,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
capitalization = KeyboardCapitalization.None
|
|
||||||
),
|
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
|
|
||||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
|
||||||
),
|
|
||||||
supportingText = {
|
|
||||||
if (cameraPackageError.value != null) {
|
|
||||||
Text(cameraPackageError.value!!, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = { Text(stringResource(R.string.custom_camera_package)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val successText = stringResource(R.string.custom_camera_package_set_success)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (cameraPackageValue.value.isBlank()) {
|
|
||||||
sharedPreferences.edit { remove("custom_camera_package") }
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showCameraDialog.value = false
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) }
|
|
||||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
|
||||||
showCameraDialog.value = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Save",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showCameraDialog.value = false }
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.view.accessibility.AccessibilityManager
|
|
||||||
import android.accessibilityservice.AccessibilityServiceInfo
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.SelectItem
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSelectList
|
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.services.AppListenerService
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun CameraControlScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val service = ServiceManager.getService()!!
|
|
||||||
var currentCameraAction by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isAppListenerServiceEnabled(context: Context): Boolean {
|
|
||||||
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
|
|
||||||
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
|
|
||||||
val serviceComponent = ComponentName(context, AppListenerService::class.java)
|
|
||||||
return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className }
|
|
||||||
}
|
|
||||||
|
|
||||||
val cameraOptions = listOf(
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.off),
|
|
||||||
selected = currentCameraAction == null,
|
|
||||||
onClick = {
|
|
||||||
sharedPreferences.edit { remove("camera_action") }
|
|
||||||
currentCameraAction = null
|
|
||||||
}
|
|
||||||
),
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.press_once),
|
|
||||||
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
|
|
||||||
onClick = {
|
|
||||||
if (!isAppListenerServiceEnabled(context)) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
|
||||||
} else {
|
|
||||||
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
|
|
||||||
currentCameraAction = StemPressType.SINGLE_PRESS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.press_and_hold_airpods),
|
|
||||||
selected = currentCameraAction == StemPressType.LONG_PRESS,
|
|
||||||
onClick = {
|
|
||||||
if (!isAppListenerServiceEnabled(context)) {
|
|
||||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
|
||||||
} else {
|
|
||||||
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
|
|
||||||
currentCameraAction = StemPressType.LONG_PRESS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.camera_control)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
StyledSelectList(items = cameraOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
|
||||||
fun HearingProtectionScreen(navController: NavController) {
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val service = ServiceManager.getService()
|
|
||||||
if (service == null) return
|
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.hearing_protection),
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.environmental_noise),
|
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
|
||||||
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.workspace_use),
|
|
||||||
label = stringResource(R.string.ppe),
|
|
||||||
description = stringResource(R.string.workspace_use_description),
|
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Clear
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun Onboarding(navController: NavController, activityContext: Context) {
|
|
||||||
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 radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
|
|
||||||
val progressState by radareOffsetFinder.progressState.collectAsState()
|
|
||||||
var isComplete by remember { mutableStateOf(false) }
|
|
||||||
var hasStarted by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckPassed by remember { mutableStateOf(false) }
|
|
||||||
var checkingRoot by remember { mutableStateOf(false) }
|
|
||||||
var rootCheckFailed by remember { mutableStateOf(false) }
|
|
||||||
var moduleEnabled by remember { mutableStateOf(false) }
|
|
||||||
var bluetoothToggled by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var showSkipDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
fun checkRootAccess() {
|
|
||||||
checkingRoot = true
|
|
||||||
rootCheckFailed = false
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val process = Runtime.getRuntime().exec("su -c id")
|
|
||||||
val exitValue = process.waitFor() // no idea why i have this, probably don't need to do this
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = (exitValue == 0)
|
|
||||||
rootCheckFailed = (exitValue != 0)
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Onboarding", "Root check failed", e)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
rootCheckPassed = false
|
|
||||||
rootCheckFailed = true
|
|
||||||
checkingRoot = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(hasStarted) {
|
|
||||||
if (hasStarted && rootCheckPassed) {
|
|
||||||
Log.d("Onboarding", "Checking if hook offset is available...")
|
|
||||||
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
|
|
||||||
Log.d("Onboarding", "Hook offset ready: $isHookReady")
|
|
||||||
|
|
||||||
if (isHookReady) {
|
|
||||||
Log.d("Onboarding", "Hook is ready")
|
|
||||||
isComplete = true
|
|
||||||
} else {
|
|
||||||
Log.d("Onboarding", "Hook not ready, starting setup process...")
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(progressState) {
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Success) {
|
|
||||||
isComplete = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
StyledScaffold(
|
|
||||||
title = "Setting Up",
|
|
||||||
actionButtons = listOf(
|
|
||||||
{scaffoldBackdrop ->
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = {
|
|
||||||
showSkipDialog = true
|
|
||||||
},
|
|
||||||
icon = "",
|
|
||||||
darkMode = isDarkTheme,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
if (!rootCheckPassed && !hasStarted) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Root Access",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.root_access_required),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (rootCheckFailed) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.root_access_denied),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = Color(0xFFFF453A)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { checkRootAccess() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
enabled = !checkingRoot
|
|
||||||
) {
|
|
||||||
if (checkingRoot) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"Check Root Access",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted) getStatusTitle(progressState,
|
|
||||||
moduleEnabled, bluetoothToggled) else "Setup Required",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = if (hasStarted)
|
|
||||||
getStatusDescription(progressState, moduleEnabled, bluetoothToggled)
|
|
||||||
else
|
|
||||||
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() }
|
|
||||||
) { text ->
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
if (!hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = { hasStarted = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Start Setup",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
|
|
||||||
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
|
|
||||||
val animatedProgress by animateFloatAsState(
|
|
||||||
targetValue = progress,
|
|
||||||
label = "Download Progress"
|
|
||||||
)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { animatedProgress },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${(progress * 100).toInt()}%",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
if (!moduleEnabled) {
|
|
||||||
Button(
|
|
||||||
onClick = { moduleEnabled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Enabled/Reactivated the Module",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!bluetoothToggled) {
|
|
||||||
Button(
|
|
||||||
onClick = { bluetoothToggled = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"I've Toggled Bluetooth",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
navController.navigate("settings") {
|
|
||||||
popUpTo("onboarding") { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(50.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Continue to Settings",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle,
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
// No specific UI for these states
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
strokeCap = StrokeCap.Round,
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
Log.d("Onboarding", "Trying to find offset again...")
|
|
||||||
kotlinx.coroutines.MainScope().launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
radareOffsetFinder.setupAndFindOffset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(55.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = accentColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Try Again",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSkipDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showSkipDialog = false },
|
|
||||||
title = { Text("Skip Setup") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showSkipDialog = false
|
|
||||||
RadareOffsetFinder.clearHookOffsets()
|
|
||||||
sharedPreferences.edit { putBoolean("skip_setup", true) }
|
|
||||||
navController.navigate("settings") {
|
|
||||||
popUpTo("onboarding") { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Yes, Skip Setup",
|
|
||||||
color = accentColor,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showSkipDialog = false }
|
|
||||||
) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = backgroundColor,
|
|
||||||
textContentColor = textColor,
|
|
||||||
titleContentColor = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatusIcon(
|
|
||||||
progressState: RadareOffsetFinder.ProgressState,
|
|
||||||
isDarkTheme: Boolean
|
|
||||||
) {
|
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
|
|
||||||
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(80.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
when (progressState) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Clear,
|
|
||||||
contentDescription = "Error",
|
|
||||||
tint = errorColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = "Success",
|
|
||||||
tint = successColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Settings,
|
|
||||||
contentDescription = "Settings",
|
|
||||||
tint = accentColor,
|
|
||||||
modifier = Modifier.size(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(50.dp),
|
|
||||||
color = accentColor,
|
|
||||||
strokeWidth = 4.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusTitle(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Enable Xposed Module"
|
|
||||||
!bluetoothToggled -> "Toggle Bluetooth"
|
|
||||||
else -> "Setup Complete"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStatusDescription(
|
|
||||||
state: RadareOffsetFinder.ProgressState,
|
|
||||||
moduleEnabled: Boolean,
|
|
||||||
bluetoothToggled: Boolean
|
|
||||||
): String {
|
|
||||||
return when (state) {
|
|
||||||
is RadareOffsetFinder.ProgressState.Success -> {
|
|
||||||
when {
|
|
||||||
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
|
|
||||||
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
|
|
||||||
else -> "All set! You can now use your AirPods with enhanced functionality."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
|
|
||||||
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
|
|
||||||
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
|
|
||||||
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
|
|
||||||
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
|
|
||||||
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
|
|
||||||
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
|
|
||||||
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
|
|
||||||
is RadareOffsetFinder.ProgressState.Error -> state.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun OnboardingPreview() {
|
|
||||||
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextRange
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
|
||||||
fun RenameScreen(navController: NavController) {
|
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
keyboardController?.show()
|
|
||||||
name.value = name.value.copy(selection = TextRange(name.value.text.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.name),
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val cursorColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(58.dp)
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
BasicTextField(
|
|
||||||
value = name.value,
|
|
||||||
onValueChange = {
|
|
||||||
name.value = it
|
|
||||||
sharedPreferences.edit {putString("name", it.text)}
|
|
||||||
ServiceManager.getService()?.setName(it.text)
|
|
||||||
},
|
|
||||||
textStyle = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
singleLine = true,
|
|
||||||
cursorBrush = SolidColor(cursorColor),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
name.value = TextFieldValue("")
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 8.dp)
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun RenameScreenPreview() {
|
|
||||||
RenameScreen(navController = NavController(LocalContext.current))
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user