Compare commits

..
Author SHA1 Message Date
Kavish Devar 04ef891c4b disable DI hook and dependant functions 2026-03-29 16:28:45 +05:30
Kavish Devar 555a000def add A16QPR3 constructor for ATT
i really need to clean this up
2026-03-29 16:22:16 +05:30
Kavish Devar 45f53d5cd4 actually bring back DI hook 2026-03-29 16:12:12 +05:30
Kavish Devar 1425a7d7cf bump version 2026-03-29 16:07:39 +05:30
Kavish Devar e8204a7750 add A16QPR3 constructor
it's almost as if google is doing this deliberately doing this
2026-03-29 16:07:23 +05:30
Kavish Devar 5fbfda6115 add back DI hook 2026-03-29 16:04:50 +05:30
Kavish Devar a6a284c2ec android: disable DI hook
for CI
2026-03-18 08:52:28 +05:30
Kavish Devar 5c0d9b5096 remove radare2 onboarding
mostly ai generated, but I've tested it.
2026-02-24 19:01:31 +05:30
Kavish Devar 752b53aecd Merge branch 'main' into android/testing 2026-02-24 15:58:44 +05:30
Kavish Devar 3e4d401223 call original fcr_chk_chan to set preferred mode to BASIC 2025-12-18 00:43:32 +05:30
172 changed files with 6696 additions and 8615 deletions
+35 -69
View File
@@ -1,25 +1,13 @@
<p align="center">
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" />
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" />
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
<a href="https://discord.gg/HhG4ycVum4">
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
</a>
</p>
>[!IMPORTANT] >[!IMPORTANT]
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then. Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
![LibrePods Banner](./imgs/banner.png) ![LibrePods Banner](./imgs/banner.png)
# What is LibrePods? ## What is LibrePods?
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem. LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
# Device Compatibility ## Device Compatibility
| Status | Device | Features | | Status | Device | Features |
| ------ | --------------------- | ---------------------------------------------------------- | | ------ | --------------------- | ---------------------------------------------------------- |
@@ -30,7 +18,7 @@ LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get a
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS). Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS).
# Key Features ## Key Features
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press - **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out - **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
@@ -48,18 +36,18 @@ Most features should work with any AirPods. Currently, I've only got AirPods Pro
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple. &ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
# Platform Support ## Platform Support
## Linux ### Linux
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it) for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241)) new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28) ![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
## Android ### Android
### Screenshots #### Screenshots
| | | | | | | |
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | | --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
@@ -74,14 +62,34 @@ here's a very unprofessional demo video
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533 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. If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
For everyone else: > [!CAUTION]
**You must have a rooted device with Xposed to use LibrePods on Android.** > **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. DO NOT leave a +1 comment - use the +1 button in the top right of the page next to the "Hotlists" field. Leaving +1 comment spam makes it impossible for developers to engage in the necessary technical discussion to implement this fix, and will disincentivize the responsible Google developers from engaging. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+.
>
> There are **no exceptions** to the root requirement until Google/your OEM figures out a fix.
### A few notes Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
## Changing VendorID in the DID profile to that of Apple
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
### Multi-device Connectivity
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
### Accessibility Settings and Hearing Aid
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
#### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced. - Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
@@ -91,23 +99,7 @@ For everyone else:
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module. - If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
# Changing VendorID in the DID profile to that of Apple ## Supporters
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
## Multi-device Connectivity
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
## Accessibility Settings and Hearing Aid
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
# Supporters
A huge thank you to everyone supporting the project! A huge thank you to everyone supporting the project!
- @davdroman - @davdroman
@@ -117,40 +109,14 @@ A huge thank you to everyone supporting the project!
- @lunaroyster - @lunaroyster
- @ressiwage - @ressiwage
# Special thanks ## Special thanks
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion)) - @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016)) - @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
- @devnoname120 for helping with the first root patch - @devnoname120 for helping with the first root patch
- @timgromeyer for making the first version of the linux app - @timgromeyer for making the first version of the linux app
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)! - @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
# Alternates for other platforms: ## Star History
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
# Nightly / Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
### Android
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
2. Click on the most recent successful workflow run
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
4. Extract the zip and install the `.apk` on your device
> [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update — you may need to uninstall the stable version first.
### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
2. Click on the most recent successful workflow run
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
> [!WARNING]
> Nightly builds are unstable and may contain bugs. Use at your own risk.
# Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left"> <a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
<picture> <picture>
+20 -77
View File
@@ -1,73 +1,42 @@
import java.util.Properties
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 = 37 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 minSdk = 33
targetSdk = 37 targetSdk = 36
versionCode = 28 versionCode = 10
versionName = "0.2.0" versionName = "0.2.0-alpha.2"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = false
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")
}
create("playRelease") {
initWith(getByName("release"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
buildConfig = true
} }
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
@@ -80,41 +49,18 @@ android {
} }
sourceSets { sourceSets {
getByName("main") { getByName("main") {
res.directories+="src/main/res-apple" res.srcDirs("src/main/res", "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"
}
}
versionNameSuffix = "-xposed"
} }
} }
} }
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.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)
@@ -126,18 +72,15 @@ 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)
implementation(libs.backdrop) // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
// implementation(libs.hilt) // implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
// implementation(libs.hilt.compiler) compileOnly(files("libs/libxposed-api-100.aar"))
add("xposedCompileOnly", libs.libxposed.api) debugImplementation(files("libs/backdrop-debug.aar"))
add("xposedImplementation", libs.libxposed.service) releaseImplementation(files("libs/backdrop-release.aar"))
add("playReleaseImplementation", libs.billing)
} }
aboutLibraries { aboutLibraries {
Binary file not shown.
Binary file not shown.
+1 -4
View File
@@ -18,7 +18,4 @@
# 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 androidx.compose.** { *; }
-dontwarn androidx.compose.**
+23 -24
View File
@@ -14,9 +14,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- <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.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,17 +26,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.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"--> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
<!-- android:maxSdkVersion="30" />--> android:maxSdkVersion="30" />
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"--> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
<!-- android:maxSdkVersion="30" />--> android:maxSdkVersion="30" />
<uses-permission android:name="com.android.vending.BILLING" />
<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"
@@ -47,9 +45,10 @@
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=".presentation.widgets.NoiseControlWidget" android:name=".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" />
@@ -61,7 +60,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".presentation.widgets.BatteryWidget" android:name=".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" />
@@ -115,17 +114,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"
+21 -50
View File
@@ -3,61 +3,32 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook") project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
option(IS_XPOSED "Build Xposed components" OFF) add_library(l2c_fcr_hook SHARED
l2c_fcr_hook.cpp
add_library(bluetooth_socket SHARED xz/xz_crc32.c
bluetooth_socket.cpp xz/xz_crc64.c
xz/xz_sha256.c
xz/xz_dec_stream.c
xz/xz_dec_lzma2.c
xz/xz_dec_bcj.c
) )
target_compile_options(bluetooth_socket PRIVATE target_include_directories(l2c_fcr_hook PRIVATE
-O2 xz
-fvisibility=hidden
) )
target_link_options(bluetooth_socket PRIVATE target_compile_definitions(l2c_fcr_hook PRIVATE
-Wl,--strip-all XZ_DEC_X86
-Wl,--gc-sections 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(bluetooth_socket target_link_libraries(l2c_fcr_hook
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()
@@ -1,68 +0,0 @@
#include <jni.h>
#include <pthread.h>
#include <array>
#include <string>
static JavaVM* gVm = nullptr;
template<size_t N>
constexpr auto encryptString(const char (&str)[N], char key) {
std::array<char, N> encrypted{};
for (size_t i = 0; i < N; i++) {
encrypted[i] = str[i] ^ key;
}
return encrypted;
}
template<size_t N>
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
std::string result(N - 1, '\0');
for (size_t i = 0; i < N - 1; i++) {
result[i] = encrypted[i] ^ key;
}
return result;
}
#define ENC(str) encryptString(str, 0x47)
#define DEC(arr) decryptString(arr, 0x47).c_str()
__attribute__((visibility("hidden")))
static JavaVM* getVm() { return gVm; }
__attribute__((visibility("default")))
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gVm = vm;
auto fn = [](void*) -> void* {
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
constexpr auto c2 = ENC("getRuntime");
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
constexpr auto c4 = ENC("setHiddenApiExemptions");
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
jclass vmRuntime = env->FindClass(DEC(c1));
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
jobjectArray prefixes = env->NewObjectArray(
2, env->FindClass(DEC(c6)), nullptr);
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
env->CallVoidMethod(runtime, setExemptions, prefixes);
getVm()->DetachCurrentThread();
return nullptr;
};
pthread_t t;
pthread_create(&t, nullptr, fn, nullptr);
pthread_join(t, nullptr);
return JNI_VERSION_1_6;
}
@@ -24,8 +24,6 @@
#include <unistd.h> #include <unistd.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <elf.h> #include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h" #include "l2c_fcr_hook.h"
@@ -33,7 +31,7 @@ extern "C" {
#include "xz.h" #include "xz.h"
} }
#define LOG_TAG "LibrePodsHook" #define LOG_TAG "LibrePods"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
@@ -43,15 +41,13 @@ static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)( static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
tSDP_DI_RECORD*, uint32_t*) = nullptr; tSDP_DI_RECORD*, uint32_t*) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) { uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("fake_l2c_fcr_chk_chan_modes called"); LOGI("l2c_fcr_chk_chan_modes hooked");
uint8_t orig = 0; uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes) if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb); orig = original_l2c_fcr_chk_chan_modes(p_ccb);
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig); LOGI("Original returned %d, forcing 1", orig);
return 1; return 1;
} }
@@ -59,19 +55,17 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
tSDP_DI_RECORD* p_device_info, tSDP_DI_RECORD* p_device_info,
uint32_t* p_handle) { uint32_t* p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called"); LOGI("BTA_DmSetLocalDiRecord hooked");
if (original_BTA_DmSetLocalDiRecord && enableSdpHook.load(std::memory_order_relaxed)) original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
if (p_device_info) { if (p_device_info) {
p_device_info->vendor = 0x004C; p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001; p_device_info->vendor_id_source = 0x0001;
} }
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE); if (original_BTA_DmSetLocalDiRecord)
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE; return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
return BTA_FAILURE;
} }
static bool decompressXZ( static bool decompressXZ(
@@ -79,19 +73,13 @@ static bool decompressXZ(
size_t input_size, size_t input_size,
std::vector<uint8_t>& output) { std::vector<uint8_t>& output) {
LOGI("decompressXZ called with input_size: %zu", input_size);
xz_crc32_init(); xz_crc32_init();
#ifdef XZ_USE_CRC64 #ifdef XZ_USE_CRC64
xz_crc64_init(); xz_crc64_init();
#endif #endif
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20); struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
if (!dec) { if (!dec) return false;
LOGE("decompressXZ: xz_dec_init failed");
return false;
}
LOGI("decompressXZ: xz_dec_init succeeded");
struct xz_buf buf{}; struct xz_buf buf{};
buf.in = input; buf.in = input;
@@ -104,25 +92,19 @@ static bool decompressXZ(
buf.out_pos = 0; buf.out_pos = 0;
buf.out_size = output.size(); buf.out_size = output.size();
LOGI("decompressXZ: entering decompression loop");
while (true) { while (true) {
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos, buf.out_pos);
enum xz_ret ret = xz_dec_run(dec, &buf); enum xz_ret ret = xz_dec_run(dec, &buf);
LOGI("decompressXZ: xz_dec_run returned %d", ret);
if (ret == XZ_STREAM_END) if (ret == XZ_STREAM_END)
break; break;
if (ret != XZ_OK) { if (ret != XZ_OK) {
LOGE("decompressXZ: xz_dec_run error");
xz_dec_end(dec); xz_dec_end(dec);
return false; return false;
} }
if (buf.out_pos == buf.out_size) { if (buf.out_pos == buf.out_size) {
size_t old = output.size(); size_t old = output.size();
LOGI("decompressXZ: resizing output to %zu", old * 2);
output.resize(old * 2); output.resize(old * 2);
buf.out = output.data(); buf.out = output.data();
buf.out_size = output.size(); buf.out_size = output.size();
@@ -131,30 +113,21 @@ static bool decompressXZ(
output.resize(buf.out_pos); output.resize(buf.out_pos);
xz_dec_end(dec); xz_dec_end(dec);
LOGI("decompressXZ: decompression successful, output size: %zu", output.size());
return true; return true;
} }
static bool getLibraryPath(const char* name, std::string& out) { static bool getLibraryPath(const char* name, std::string& out) {
LOGI("getLibraryPath called with name: %s", name);
FILE* fp = fopen("/proc/self/maps", "r"); FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) { if (!fp) return false;
LOGE("getLibraryPath: fopen failed");
return false;
}
char line[1024]; char line[1024];
LOGI("getLibraryPath: scanning /proc/self/maps");
while (fgets(line, sizeof(line), fp)) { while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) { if (strstr(line, name)) {
LOGI("getLibraryPath: found line containing %s", name);
char* path = strchr(line, '/'); char* path = strchr(line, '/');
if (path) { if (path) {
out = path; out = path;
out.erase(out.find('\n')); out.erase(out.find('\n'));
LOGI("getLibraryPath: path found: %s", out.c_str());
fclose(fp); fclose(fp);
return true; return true;
} }
@@ -162,33 +135,24 @@ static bool getLibraryPath(const char* name, std::string& out) {
} }
fclose(fp); fclose(fp);
LOGI("getLibraryPath: failed to find path for %s", name);
return false; return false;
} }
static uintptr_t getModuleBase(const char* name) { static uintptr_t getModuleBase(const char* name) {
LOGI("getModuleBase called with name: %s", name);
FILE* fp = fopen("/proc/self/maps", "r"); FILE* fp = fopen("/proc/self/maps", "r");
if (!fp) { if (!fp) return 0;
LOGE("getModuleBase: fopen failed");
return 0;
}
char line[1024]; char line[1024];
uintptr_t base = 0; uintptr_t base = 0;
LOGI("getModuleBase: scanning /proc/self/maps");
while (fgets(line, sizeof(line), fp)) { while (fgets(line, sizeof(line), fp)) {
if (strstr(line, name)) { if (strstr(line, name)) {
base = strtoull(line, nullptr, 16); base = strtoull(line, nullptr, 16);
LOGI("getModuleBase: found base at 0x%lx", base);
break; break;
} }
} }
fclose(fp); fclose(fp);
LOGI("getModuleBase: failed to find base for %s", name);
return base; return base;
} }
@@ -196,8 +160,6 @@ static uint64_t findSymbolOffset(
const std::vector<uint8_t>& elf, const std::vector<uint8_t>& elf,
const char* symbol_substring) { const char* symbol_substring) {
LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring);
auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data()); auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data());
auto* shdr = reinterpret_cast<const Elf64_Shdr*>( auto* shdr = reinterpret_cast<const Elf64_Shdr*>(
elf.data() + eh->e_shoff); elf.data() + eh->e_shoff);
@@ -209,7 +171,6 @@ static uint64_t findSymbolOffset(
const Elf64_Shdr* symtab = nullptr; const Elf64_Shdr* symtab = nullptr;
const Elf64_Shdr* strtab = nullptr; const Elf64_Shdr* strtab = nullptr;
LOGI("findSymbolOffset: parsing ELF sections");
for (int i = 0; i < eh->e_shnum; ++i) { for (int i = 0; i < eh->e_shnum; ++i) {
const char* secname = shstr + shdr[i].sh_name; const char* secname = shstr + shdr[i].sh_name;
if (!strcmp(secname, ".symtab")) if (!strcmp(secname, ".symtab"))
@@ -218,11 +179,8 @@ static uint64_t findSymbolOffset(
strtab = &shdr[i]; strtab = &shdr[i];
} }
if (!symtab || !strtab) { if (!symtab || !strtab)
LOGE("findSymbolOffset: symtab or strtab not found");
return 0; return 0;
}
LOGI("findSymbolOffset: found symtab and strtab");
auto* symbols = reinterpret_cast<const Elf64_Sym*>( auto* symbols = reinterpret_cast<const Elf64_Sym*>(
elf.data() + symtab->sh_offset); elf.data() + symtab->sh_offset);
@@ -233,25 +191,24 @@ static uint64_t findSymbolOffset(
size_t count = symtab->sh_size / sizeof(Elf64_Sym); size_t count = symtab->sh_size / sizeof(Elf64_Sym);
LOGI("findSymbolOffset: scanning %zu symbols", count);
for (size_t i = 0; i < count; ++i) { for (size_t i = 0; i < count; ++i) {
const char* name = strings + symbols[i].st_name; const char* name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && if (strstr(name, symbol_substring) &&
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) { ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name, (unsigned long)symbols[i].st_value); LOGI("Resolved %s at 0x%lx",
name,
(unsigned long)symbols[i].st_value);
return symbols[i].st_value; return symbols[i].st_value;
} }
} }
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
return 0; return 0;
} }
static bool hookLibrary(const char* libname) { static bool hookLibrary(const char* libname) {
LOGI("hookLibrary called with libname: %s", libname);
if (!hook_func) { if (!hook_func) {
LOGE("hook_func not initialized"); LOGE("hook_func not initialized");
@@ -263,21 +220,15 @@ static bool hookLibrary(const char* libname) {
LOGE("Failed to locate %s", libname); LOGE("Failed to locate %s", libname);
return false; return false;
} }
LOGI("hookLibrary: located path: %s", path.c_str());
int fd = open(path.c_str(), O_RDONLY); int fd = open(path.c_str(), O_RDONLY);
if (fd < 0) { if (fd < 0) return false;
LOGE("hookLibrary: open failed");
return false;
}
struct stat st{}; struct stat st{};
if (fstat(fd, &st) != 0) { if (fstat(fd, &st) != 0) {
LOGE("hookLibrary: fstat failed");
close(fd); close(fd);
return false; return false;
} }
LOGI("hookLibrary: opened file, size: %lld", (long long)st.st_size);
std::vector<uint8_t> file(st.st_size); std::vector<uint8_t> file(st.st_size);
read(fd, file.data(), st.st_size); read(fd, file.data(), st.st_size);
@@ -291,11 +242,9 @@ static bool hookLibrary(const char* libname) {
reinterpret_cast<const char*>( reinterpret_cast<const char*>(
file.data() + shdr[eh->e_shstrndx].sh_offset); file.data() + shdr[eh->e_shstrndx].sh_offset);
LOGI("hookLibrary: parsing ELF header and sections");
for (int i = 0; i < eh->e_shnum; ++i) { for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) { if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed( std::vector<uint8_t> compressed(
file.begin() + shdr[i].sh_offset, file.begin() + shdr[i].sh_offset,
@@ -306,28 +255,19 @@ static bool hookLibrary(const char* libname) {
if (!decompressXZ( if (!decompressXZ(
compressed.data(), compressed.data(),
compressed.size(), compressed.size(),
decompressed)) { decompressed))
LOGE("hookLibrary: decompressXZ failed");
return false; return false;
}
LOGI("hookLibrary: decompressed debug data, size: %zu", decompressed.size());
uintptr_t base = getModuleBase(libname); uintptr_t base = getModuleBase(libname);
if (!base) { if (!base) return false;
LOGE("hookLibrary: getModuleBase failed");
return false;
}
LOGI("hookLibrary: module base: 0x%lx", base);
uint64_t chk_offset = uint64_t chk_offset =
findSymbolOffset(decompressed, findSymbolOffset(decompressed,
"l2c_fcr_chk_chan_modes"); "l2c_fcr_chk_chan_modes");
uint64_t sdp_offset = // uint64_t sdp_offset =
findSymbolOffset(decompressed, // findSymbolOffset(decompressed,
"BTA_DmSetLocalDiRecord"); // "BTA_DmSetLocalDiRecord");
LOGI("hookLibrary: chk_offset: 0x%lx, sdp_offset: 0x%lx", chk_offset, sdp_offset);
if (chk_offset) { if (chk_offset) {
void* target = void* target =
@@ -337,30 +277,28 @@ static bool hookLibrary(const char* libname) {
(void*)fake_l2c_fcr_chk_chan_modes, (void*)fake_l2c_fcr_chk_chan_modes,
(void**)&original_l2c_fcr_chk_chan_modes); (void**)&original_l2c_fcr_chk_chan_modes);
LOGI("hookLibrary: hooked l2c_fcr_chk_chan_modes"); LOGI("Hooked l2c_fcr_chk_chan_modes");
} }
if (sdp_offset) { // if (sdp_offset) {
void* target = // void* target =
reinterpret_cast<void*>(base + sdp_offset); // reinterpret_cast<void*>(base + sdp_offset);
//
hook_func(target, // hook_func(target,
(void*)fake_BTA_DmSetLocalDiRecord, // (void*)fake_BTA_DmSetLocalDiRecord,
(void**)&original_BTA_DmSetLocalDiRecord); // (void**)&original_BTA_DmSetLocalDiRecord);
//
LOGI("hookLibrary: hooked BTA_DmSetLocalDiRecord"); // LOGI("Hooked BTA_DmSetLocalDiRecord");
} // }
return true; return true;
} }
} }
LOGI("hookLibrary: failed for %s", libname);
return false; return false;
} }
static void on_library_loaded(const char* name, void*) { static void on_library_loaded(const char* name, void*) {
LOGI("on_library_loaded called with name: %s", name);
if (strstr(name, "libbluetooth_jni.so")) { if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Bluetooth JNI loaded"); LOGI("Bluetooth JNI loaded");
@@ -377,18 +315,10 @@ extern "C"
[[gnu::visibility("default")]] [[gnu::visibility("default")]]
[[gnu::used]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) { NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("native_init called with entries: %p", entries);
LOGI("LibrePods initialized");
hook_func = (HookFunType)entries->hook_func; hook_func = (HookFunType)entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded; return on_library_loaded;
} }
extern "C"
JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(
JNIEnv*, jobject thiz, jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed);
LOGI("sdp hook enabled: %d", enable);
}
@@ -20,9 +20,6 @@
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
@@ -30,11 +27,13 @@ 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,9 +51,7 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally 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.gestures.detectTapGestures
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
@@ -64,7 +61,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -77,12 +73,11 @@ 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
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.setValue import androidx.compose.runtime.setValue
@@ -93,9 +88,8 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -109,7 +103,6 @@ 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
@@ -119,65 +112,55 @@ 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 dev.chrisbanes.haze.rememberHazeState import me.kavishdevar.librepods.composables.StyledIconButton
import kotlinx.coroutines.delay import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.screens.CameraControlScreen
import me.kavishdevar.librepods.presentation.components.StyledIconButton import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.DebugScreen // import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.LongPress import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.screens.VersionScreen
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.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.isSupported import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
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 {
if (BuildConfig.FLAVOR == "xposed") { System.loadLibrary("l2c_fcr_hook")
System.loadLibrary("l2c_fcr_hook")
}
} }
} }
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme { LibrePodsTheme {
Main() Main()
} }
} }
handleIncomingIntent(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -212,166 +195,82 @@ 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, ExperimentalMaterial3Api::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun Main() { fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) }
val blockTouches = remember { mutableStateOf(false) }
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val hazeState = rememberHazeState()
LaunchedEffect(blockTouches) {
if (blockTouches.value) {
delay(500)
blockTouches.value = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Box (
modifier = Modifier
.fillMaxSize()
.then(
if (blockTouches.value)
{
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
}
else Modifier
)
)
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Not supported",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 20.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row (
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 >= 7) {
showDialog.value = true
blockTouches.value = true
}
}
)
},
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Device Info:",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.End,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
"MANUFACTURER=${Build.MANUFACTURER}\n" +
"MODEL=${Build.MODEL}\n" +
"BUILD_ID=${Build.ID}\n" +
"SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.hack)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.Start,
)
}
Text(
text = "Check the repository for more info.",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 18.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
ConfirmationDialog(
showDialog = showDialog,
title = "Confirm device check bypass?",
message = "Are you sure your device is supported with LibrePods?",
confirmText = "Yes",
dismissText = "No",
onConfirm = {
showDialog.value = false
sharedPreferences.edit {
tapCount.intValue = 0
putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
onDismiss = {
showDialog.value = false
},
hazeState = hazeState
)
return
}
val isConnected = remember { mutableStateOf(false) } val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
mutableStateOf(
context.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("overlay_permission_skipped", false)
)
}
BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
@@ -398,33 +297,23 @@ 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.fillMaxSize() modifier = Modifier
) { .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))
@@ -432,130 +321,129 @@ fun Main() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "settings", startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
enterTransition = { enterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { it }, animationSpec = tween(durationMillis = 300) initialOffsetX = { it },
) animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
}, },
exitTransition = { exitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300) targetOffsetX = { -it/4 },
) 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 }, animationSpec = tween(durationMillis = 300) targetOffsetX = { it },
) animationSpec = tween(durationMillis = 300)
}) { ) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") { composable("settings") {
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController) if (airPodsService.value != null) {
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 ->
if (airPodsViewModel != null) LongPress( LongPress(
viewModel = airPodsViewModel, navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!, name = navBackStackEntry.arguments?.getString("bud")!!
navController = navController
) )
} }
composable("rename") { composable("rename") {
if (airPodsViewModel != null) RenameScreen(airPodsViewModel) RenameScreen(navController)
} }
composable("app_settings") { composable("app_settings") {
val appSettingsViewModel: AppSettingsViewModel = viewModel() AppSettingsScreen(navController)
AppSettingsScreen(navController, appSettingsViewModel)
} }
composable("troubleshooting") { composable("troubleshooting") {
TroubleshootingScreen(navController) TroubleshootingScreen(navController)
} }
composable("head_tracking") { composable("head_tracking") {
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController) HeadTrackingScreen()
} }
/*composable("onboarding") {
Onboarding(navController, context)
}*/
composable("accessibility") { composable("accessibility") {
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController) AccessibilitySettingsScreen(navController)
} }
composable("transparency_customization") { composable("transparency_customization") {
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel) TransparencySettingsScreen(navController)
} }
composable("hearing_aid") { composable("hearing_aid") {
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController) HearingAidScreen(navController)
} }
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel) HearingAidAdjustmentsScreen(navController)
} }
composable("adaptive_strength") { composable("adaptive_strength") {
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController) AdaptiveStrengthScreen(navController)
} }
composable("camera_control") { composable("camera_control") {
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel) CameraControlScreen(navController)
} }
composable("open_source_licenses") { composable("open_source_licenses") {
OpenSourceLicensesScreen(navController) OpenSourceLicensesScreen(navController)
} }
composable("update_hearing_test") { composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen() UpdateHearingTestScreen(navController)
} }
composable("version_info") { composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel) VersionScreen(navController)
} }
composable("hearing_protection") { composable("hearing_protection") {
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel) HearingProtectionScreen(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 = showBackButton.value = destination.route != "settings" // && destination.route != "onboarding"
destination.route != "settings" // && destination.route != "onboarding" Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
Log.d(
"MainActivity",
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
)
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = showBackButton.value, visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn( enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
initialScale = 0f, exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
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, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp start = 8.dp,
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
) )
) { ) {
StyledIconButton( StyledIconButton(
onClick = { navController.popBackStack() }, onClick = { navController.popBackStack() },
icon = "􀯶", icon = "􀯶",
backdrop = backButtonBackdrop darkMode = isSystemInDarkTheme(),
) 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?) {
@@ -569,20 +457,17 @@ fun Main() {
} }
} }
context.bindService( context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
if (airPodsService.value?.isConnected() == true) { if (airPodsService.value?.isConnectedLocally == 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) }
)
} }
} }
@@ -605,9 +490,13 @@ fun PermissionsScreen(
val infiniteTransition = rememberInfiniteTransition(label = "pulse") val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat( val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable( initialValue = 1f,
animation = tween(1000), repeatMode = RepeatMode.Reverse targetValue = 1.05f,
), label = "pulse scale" animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
) )
Column( Column(
@@ -615,15 +504,18 @@ 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), horizontalAlignment = Alignment.CenterHorizontally .verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp), contentAlignment = Alignment.Center .height(180.dp),
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "\uDBC2\uDEB7", style = TextStyle( text = "\uDBC2\uDEB7",
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)),
@@ -659,25 +551,29 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Permission Required", style = TextStyle( text = "Permission Required",
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), style = TextStyle( text = stringResource(R.string.permissions_required),
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))
@@ -850,7 +746,8 @@ 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,
@@ -866,7 +763,8 @@ fun PermissionCard(
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = title, style = TextStyle( text = title,
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)),
@@ -875,7 +773,8 @@ fun PermissionCard(
) )
Text( Text(
text = description, style = TextStyle( text = description,
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)),
@@ -892,8 +791,11 @@ fun PermissionCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (isGranted) "" else "!", style = TextStyle( text = if (isGranted) "" else "!",
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
) )
) )
} }
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.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.presentation.components.AdaptiveRainbowBrush import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.presentation.components.IconAreaSize import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.data.AirPodsNotifications 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.services.AirPodsService
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
@@ -1,5 +0,0 @@
package me.kavishdevar.librepods.billing
object BillingManager {
lateinit var provider: BillingProvider
}
@@ -1,29 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.billing
import android.app.Activity
import kotlinx.coroutines.flow.StateFlow
interface BillingProvider {
val isPremium: StateFlow<Boolean>
val price: StateFlow<String>
fun purchase(activity: Activity)
fun queryPurchases()
}
@@ -1,33 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.billing
import android.content.Context
import me.kavishdevar.librepods.BuildConfig
object BillingProviderFactory {
fun create(context: Context): BillingProvider {
return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context)
} else {
FOSSBillingProvider(context)
}
}
}
@@ -1,74 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("Any")
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(2_000)
withContext(Dispatchers.Main) {
_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
}
}
}
@@ -1,204 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.billing
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
const val TAG = "PlayBillingProvider"
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
override val price: StateFlow<String> = _price
private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()
init {
connect()
}
private fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
scope.launch {
queryProductDetails()
queryExistingPurchases()
}
} else {
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
connect()
}
})
}
private suspend fun queryProductDetails() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(PREMIUM_PRODUCT_ID)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
).build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}")
val priceString = productDetails
?.oneTimePurchaseOfferDetails
?.formattedPrice
if (priceString != null) {
_price.value = priceString
}
} else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
}
}
private suspend fun queryExistingPurchases() {
val result = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
processPurchases(result.purchasesList)
}
override fun purchase(activity: Activity) {
val details = productDetails ?: run {
Log.e(TAG, "Product details not loaded yet")
return
}
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
)
).build()
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
}
}
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
}
}
private fun processPurchases(purchases: List<Purchase>) {
val hasPremium = purchases.any {
it.products.contains(PREMIUM_PRODUCT_ID) &&
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
// val navigateToPurchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (navigateToPurchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(navigateToPurchase.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()
}
}
}
@@ -18,12 +18,12 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
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( fun AboutCard(navController: NavController) {
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,8 +75,7 @@ fun AboutCard(
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))
) )
) )
} }
@@ -109,7 +108,7 @@ fun AboutCard(
) )
) )
Text( Text(
text = modelName, text = airpodsInstance.model.displayName,
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),
@@ -138,7 +137,7 @@ fun AboutCard(
) )
) )
Text( Text(
text = actualModel, text = airpodsInstance.actualModelNumber,
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),
@@ -153,11 +152,11 @@ fun AboutCard(
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
val serialNumbers = listOf( val serialNumbers = listOf(
serialNumbers[0], airpodsInstance.serialNumber?: "",
"􀀛 ${serialNumbers[1]}", "􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${serialNumbers[2]}" "􀀧 ${airpodsInstance.rightSerialNumber}"
) )
val serialNumber = remember { mutableIntStateOf(0) } val serialNumber = remember { mutableStateOf(0) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -173,7 +172,7 @@ fun AboutCard(
), ),
) )
Text( Text(
text = serialNumbers[serialNumber.intValue], text = serialNumbers[serialNumber.value],
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),
@@ -184,7 +183,7 @@ fun AboutCard(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
} }
) )
} }
@@ -198,9 +197,9 @@ fun AboutCard(
to = "version_info", to = "version_info",
navController = navController, navController = navController,
name = stringResource(R.string.version), name = stringResource(R.string.version),
currentState = version, currentState = airpodsInstance.version3,
independent = false, independent = false,
height = rowHeight.value + 32.dp height = rowHeight.value + 32.dp
) )
} }
} }
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -35,8 +35,6 @@ 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
@@ -44,32 +42,25 @@ 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( fun AudioSettings(navController: NavController) {
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 (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) { if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return return
} }
Box( Box(
@@ -82,8 +73,7 @@ fun AudioSettings(
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))
) )
) )
} }
@@ -98,14 +88,12 @@ fun AudioSettings(
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
if (adaptiveVolumeCapability) { if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
independent = false, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
checked = adaptiveVolumeChecked, independent = false
onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
@@ -116,14 +104,12 @@ fun AudioSettings(
) )
} }
if (conversationalAwarenessCapability) { if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
independent = false, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
checked = conversationalAwarenessChecked, independent = false
onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -133,14 +119,12 @@ fun AudioSettings(
) )
} }
if (loudSoundReductionCapability && vendorIdHook){ if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
independent = false, attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
checked = loudSoundReductionChecked, independent = false
onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -150,7 +134,7 @@ fun AudioSettings(
) )
} }
if (adaptiveAudioCapability) { if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
NavigationButton( NavigationButton(
to = "adaptive_strength", to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
@@ -164,19 +148,5 @@ fun AudioSettings(
@Preview @Preview
@Composable @Composable
fun AudioSettingsPreview() { fun AudioSettingsPreview() {
AudioSettings( AudioSettings(rememberNavController())
navController = rememberNavController(),
adaptiveVolumeCapability = true,
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
vendorIdHook = true,
isPremium = true
)
} }
@@ -0,0 +1,128 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
charging: Boolean = false,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = if (batteryPercentage > 25)
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier
.background(backgroundColor), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = { batteryPercentage / 100f },
modifier = Modifier.size(40.dp),
color = batteryFillColor,
gapSize = 0.dp,
strokeCap = StrokeCap.Round,
strokeWidth = 4.dp,
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
)
Text(
text = "\uDBC0\uDEE6",
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
),
modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
}
}
@@ -0,0 +1,235 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
batteryStatus.value =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
intent.getParcelableArrayListExtra("data")
}?.toList() ?: listOf()
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
}
catch (_: IllegalArgumentException) {
Log.wtf("BatteryReceiver", "Receiver already unregistered")
}
}
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter()
.apply {
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
batteryReceiver,
batteryIntentFilter,
Context.RECEIVER_EXPORTED
)
}
}
previousBatteryStatus.value = batteryStatus.value
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
)
previousBatteryStatus.value = batteryStatus.value
}
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row {
Column (
modifier = Modifier
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3
)
{
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
leftCharging,
previousCharging = (prevLeftCharging && prevRightCharging)
)
singleDisplayed.value = true
}
else {
singleDisplayed.value = false
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
leftCharging,
"\uDBC6\uDCE5",
previousCharging = prevLeftCharging
)
}
if (leftLevel > 0 && rightLevel > 0)
{
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
{
BatteryIndicator(
rightLevel,
rightCharging,
"\uDBC6\uDCE8",
previousCharging = prevRightCharging
)
}
}
}
}
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
caseCharging,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
previousCharging = prevCaseCharging
)
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryView(AirPodsService(), preview = true)
}
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -36,43 +36,39 @@ 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( fun CallControlSettings(hazeState: HazeState) {
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)
@@ -86,8 +82,7 @@ fun CallControlSettings(
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))
) )
) )
} }
@@ -98,13 +93,24 @@ fun CallControlSettings(
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val scope = rememberCoroutineScope() val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
val haptics = LocalHapticFeedback.current it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
}?.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) }
@@ -114,7 +120,6 @@ fun CallControlSettings(
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) }
@@ -122,8 +127,36 @@ fun CallControlSettings(
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")
} }
@@ -198,11 +231,7 @@ fun CallControlSettings(
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
@@ -215,13 +244,13 @@ fun CallControlSettings(
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis() lastDismissTimeSingle = System.currentTimeMillis()
onCallControlValueChanged(option != pressOnceText) val bytes = if (option == pressOnceText) byteArrayOf(
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 = {
@@ -284,8 +313,11 @@ fun CallControlSettings(
doublePressAction = doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
val flipped = option != pressOnceText val bytes = if (option == pressOnceText) byteArrayOf(
onCallControlValueChanged(flipped) 0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -334,11 +366,7 @@ fun CallControlSettings(
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
@@ -351,13 +379,13 @@ fun CallControlSettings(
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis() lastDismissTimeDouble = System.currentTimeMillis()
val flipped = option == pressOnceText val bytes = if (option == pressOnceText) byteArrayOf(
onCallControlValueChanged(flipped) 0x00,
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 = {
@@ -420,8 +448,11 @@ fun CallControlSettings(
singlePressAction = singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
val flipped = option == pressOnceText val bytes = if (option == pressOnceText) byteArrayOf(
onCallControlValueChanged(flipped) 0x00,
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -430,3 +461,10 @@ fun CallControlSettings(
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings(HazeState())
}
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,16 +38,13 @@ import androidx.compose.runtime.MutableState
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -57,12 +53,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
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
@@ -80,18 +74,8 @@ fun ConfirmationDialog(
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 accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
if (showDialog.value) { if (showDialog.value) {
Dialog( Dialog(onDismissRequest = { showDialog.value = false }) {
onDismissRequest = { showDialog.value = false },
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Box( Box(
modifier = Modifier modifier = Modifier
// .fillMaxWidth(0.75f) // .fillMaxWidth(0.75f)
@@ -106,7 +90,7 @@ fun ConfirmationDialog(
) )
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp)) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
Text( Text(
title, title,
style = TextStyle( style = TextStyle(
@@ -118,7 +102,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(12.dp)) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
Text( Text(
message, message,
style = TextStyle( style = TextStyle(
@@ -129,7 +113,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
@@ -164,8 +148,6 @@ fun ConfirmationDialog(
} }
PointerEventType.Move -> { PointerEventType.Move -> {
if (isWithinBounds) { if (isWithinBounds) {
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
leftPressed = isLeft leftPressed = isLeft
rightPressed = !isLeft rightPressed = !isLeft
} else { } else {
@@ -176,12 +158,8 @@ fun ConfirmationDialog(
PointerEventType.Release -> { PointerEventType.Release -> {
if (isWithinBounds) { if (isWithinBounds) {
if (leftPressed) { if (leftPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Reject) }
onDismiss() onDismiss()
} else if (rightPressed) { } else if (rightPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Confirm) }
onConfirm() onConfirm()
} }
} }
@@ -18,8 +18,9 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
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
@@ -30,18 +31,16 @@ 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)
@@ -53,9 +52,10 @@ fun ConnectionSettings(
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.ear_detection), label = stringResource(R.string.ear_detection),
independent = false, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
checked = automaticEarDetectionEnabled, sharedPreferenceKey = "automatic_ear_detection",
onCheckedChange = onAutomaticEarDetectionChanged sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -67,9 +67,16 @@ 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),
independent = false, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
checked = automaticConnectionEnabled, sharedPreferenceKey = "automatic_connection_ctrl_cmd",
onCheckedChange = onAutomaticConnectionChanged sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
) )
} }
} }
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}
@@ -18,7 +18,7 @@
@file:Suppress("unused") @file:Suppress("unused")
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -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.presentation.components package me.kavishdevar.librepods.composables
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.data.NoiseControlMode import me.kavishdevar.librepods.constants.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E) private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
@@ -0,0 +1,109 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}
@@ -18,8 +18,9 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
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
@@ -34,41 +35,38 @@ 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( fun MicrophoneSettings(hazeState: HazeState) {
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)
@@ -79,6 +77,11 @@ fun MicrophoneSettings(
.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) {
@@ -95,13 +98,42 @@ fun MicrophoneSettings(
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)
@@ -141,11 +173,7 @@ fun MicrophoneSettings(
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
@@ -166,16 +194,12 @@ fun MicrophoneSettings(
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 = {
@@ -253,7 +277,10 @@ fun MicrophoneSettings(
microphoneAlwaysLeftText -> 0x02 microphoneAlwaysLeftText -> 0x02
else -> 0x00 else -> 0x00
} }
onMicModeValueChanged(byteValue.toByte()) service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -261,3 +288,10 @@ fun MicrophoneSettings(
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
MicrophoneSettings(HazeState())
}
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -35,25 +35,21 @@ 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
@@ -66,14 +62,10 @@ 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(
@@ -87,34 +79,23 @@ 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( .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
animatedBackgroundColor,
RoundedCornerShape(if (independent) 28.dp else 0.dp)
)
.height(height) .height(height)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
if (enabled) { backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
backgroundColor = tryAwaitRelease()
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
}, },
onTap = { onTap = {
if (enabled) { if (onClick != null) onClick() else navController.navigate(to)
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
if (onClick != null) onClick() else navController.navigate(to)
}
} }
) )
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() {
onClick = {}, onClick = {},
textColor = Color.White, textColor = Color.White,
) )
} }
@@ -18,9 +18,14 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
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
@@ -55,49 +60,64 @@ 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.data.NoiseControlMode import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
showOffListeningMode: Boolean, service: AirPodsService,
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 (!showOffListeningMode && mode == NoiseControlMode.OFF) { val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY NoiseControlMode.TRANSPARENCY
} else { } else {
mode mode
@@ -105,8 +125,9 @@ fun NoiseControlSettings(
noiseControlMode.value = targetMode noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1) if (!received && targetMode != previousMode) {
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 -> {
@@ -132,12 +153,32 @@ 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 index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1) val noiseControlIntentFilter = IntentFilter().apply {
noiseControlMode.value = NoiseControlMode.entries[index] addAction(AirPodsNotifications.ANC_DATA)
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))
@@ -149,18 +190,17 @@ 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 (showOffListeningMode) 4 else 3 val buttonCount = if (offListeningMode.value) 4 else 3
val buttonWidth = maxWidth / buttonCount val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) } val isDragging = remember { mutableStateOf(false) }
@@ -168,10 +208,10 @@ fun NoiseControlSettings(
mutableFloatStateOf( mutableFloatStateOf(
with(density) { with(density) {
when(noiseControlMode.value) { when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx() NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx() NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
} }
} }
) )
@@ -184,10 +224,10 @@ fun NoiseControlSettings(
) )
val targetOffset = buttonWidth * when(noiseControlMode.value) { val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1 NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0 NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1 NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2 NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
} }
val animatedOffset by animateFloatAsState( val animatedOffset by animateFloatAsState(
@@ -210,7 +250,7 @@ fun NoiseControlSettings(
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
if (showOffListeningMode) { if (offListeningMode.value) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -283,12 +323,13 @@ 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 (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY 0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 2 -> if (offListeningMode.value) 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)
} }
) )
@@ -306,7 +347,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.zIndex(1f) .zIndex(1f)
) { ) {
if (showOffListeningMode) { if (offListeningMode.value) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -365,7 +406,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp) .padding(top = 4.dp)
) { ) {
if (showOffListeningMode) { if (offListeningMode.value) {
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),
@@ -395,3 +436,9 @@ fun NoiseControlSettings(
} }
} }
} }
@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}
@@ -16,13 +16,17 @@
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.presentation.components package me.kavishdevar.librepods.composables
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
@@ -31,34 +35,38 @@ 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.data.StemAction import me.kavishdevar.librepods.constants.StemAction
@Composable @Composable
fun PressAndHoldSettings( fun PressAndHoldSettings(navController: NavController) {
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 leftActionText = when (leftAction) { val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.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 (rightAction) { val rightActionText = when (StemAction.valueOf(rightAction ?: 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!!"
@@ -106,3 +114,9 @@ fun PressAndHoldSettings(
) )
} }
} }
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -46,9 +46,7 @@ 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
@@ -57,7 +55,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.lens import com.kyant.backdrop.effects.refraction
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
@@ -79,8 +77,7 @@ fun StyledButton(
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val scope = rememberCoroutineScope() val animationScope = 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) }
@@ -149,12 +146,7 @@ half4 main(float2 coord) {
effects = { effects = {
vibrancy() vibrancy()
blur(2f.dp.toPx()) blur(2f.dp.toPx())
lens( refraction(12f.dp.toPx(), 24f.dp.toPx())
refractionHeight = 12f.dp.toPx(),
refractionAmount = 24f.dp.toPx(),
depthEffect = true,
chromaticAberration = true
)
}, },
layerBlock = { layerBlock = {
val width = size.width val width = size.width
@@ -166,21 +158,19 @@ 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 = translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
maxOffset * tanh(initialDerivative * offset.x / maxOffset) translationY = maxOffset * tanh(initialDerivative * offset.y / 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) {
@@ -214,10 +204,7 @@ 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( setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -244,51 +231,31 @@ half4 main(float2 coord) {
interactionSource = null, interactionSource = null,
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = { onClick = onClick
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
) )
.then( .then(
if (isInteractive) { if (isInteractive) {
Modifier.pointerInput(scope) { Modifier.pointerInput(animationScope) {
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 = {
scope.launch { animationScope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
} }
} }
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
scope.launch { animationScope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}, },
onDragEnd = { onDragEnd = { onDragStop() },
onDragStop()
},
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
scope.launch { animationScope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -302,7 +269,6 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
) )
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -49,17 +49,14 @@ 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
@@ -74,7 +71,6 @@ 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
@@ -114,9 +110,6 @@ 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) }
@@ -139,12 +132,7 @@ fun StyledDropdown(
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
val y = change.position.y val y = change.position.y
val newHoveredIndex = (y / itemHeight.toPx()).toInt() hoveredIndex = (y / itemHeight.toPx()).toInt()
if (newHoveredIndex != hoveredIndex) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
}
hoveredIndex = newHoveredIndex
lastDragPosition = change.position lastDragPosition = change.position
}, },
onDragEnd = { onDragEnd = {
@@ -156,8 +144,6 @@ 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])
} }
} }
@@ -188,7 +174,6 @@ fun StyledDropdown(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onOptionSelected(text) onOptionSelected(text)
onDismissRequest() onDismissRequest()
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -50,9 +50,7 @@ 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.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
@@ -65,7 +63,8 @@ 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.lens import com.kyant.backdrop.effects.blur
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
@@ -79,15 +78,14 @@ import kotlin.math.tanh
@Composable @Composable
fun StyledIconButton( fun StyledIconButton(
modifier: Modifier = Modifier, onClick: () -> Unit,
icon: String, icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(), backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit modifier: Modifier = Modifier,
) { ) {
val haptics = LocalHapticFeedback.current val animationScope = rememberCoroutineScope()
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) }
@@ -119,10 +117,7 @@ half4 main(float2 coord) {
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
TextButton( TextButton(
onClick = { onClick = onClick,
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)
@@ -153,12 +148,12 @@ 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 = {
val progress = progressAnimation.value.coerceIn(0f, 1f) val progress = progressAnimation.value.coerceIn(0f, 1f)
@@ -188,12 +183,7 @@ half4 main(float2 coord) {
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
drawRect( drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy( (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
progress.coerceIn(
0.15f,
0.35f
)
)
) )
}, },
onDrawFront = { onDrawFront = {
@@ -207,10 +197,7 @@ 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( setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -231,18 +218,13 @@ half4 main(float2 coord) {
} }
}, },
effects = { effects = {
lens( refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
refractionHeight = 6f.dp.toPx(), // blur(24f, TileMode.Decal)
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
}, },
) )
.pointerInput(scope) { .pointerInput(animationScope) {
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
scope.launch { animationScope.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) }
} }
@@ -250,8 +232,7 @@ half4 main(float2 coord) {
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
scope.launch { animationScope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
@@ -259,10 +240,7 @@ half4 main(float2 coord) {
onDragEnd = { onDragStop() }, onDragEnd = { onDragStop() },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
scope.launch { animationScope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -49,6 +49,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 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
@@ -56,15 +57,17 @@ 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, bottomPadding: Dp) -> Unit content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
@@ -84,7 +87,7 @@ fun StyledScaffold(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = startPadding, end = endPadding) .padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) { ) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
Box( Box(
@@ -124,12 +127,13 @@ fun StyledScaffold(
} }
} }
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp) content(topPadding + 64.dp, hazeState)
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -141,11 +145,12 @@ 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,
@@ -157,7 +162,7 @@ fun StyledScaffold(
title = title, title = title,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerValue, _, _ -> ) { spacerValue, _ ->
content(spacerValue) content(spacerValue)
} }
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -39,18 +39,16 @@ 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.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
@@ -61,10 +59,19 @@ 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>,
@@ -74,28 +81,24 @@ 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 scope = rememberCoroutineScope()
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.visible } val visibleItems = items.filter { it.enabled }
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(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) } var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row( Row(
@@ -105,18 +108,10 @@ fun StyledSelectList(
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
if (item.enabled) { itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
itemBackgroundColor = tryAwaitRelease()
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) itemBackgroundColor = backgroundColor
tryAwaitRelease() item.onClick()
itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
item.onClick()
}
} }
) )
} }
@@ -126,7 +121,7 @@ fun StyledSelectList(
) { ) {
if (hasIcon) { if (hasIcon) {
Icon( Icon(
painter = painterResource(item.iconRes), painter = painterResource(item.iconRes!!),
contentDescription = "Icon", contentDescription = "Icon",
tint = Color(0xFF007AFF), tint = Color(0xFF007AFF),
modifier = Modifier modifier = Modifier
@@ -186,4 +181,4 @@ fun StyledSelectList(
} }
} }
} }
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
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
@@ -44,6 +43,7 @@ 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,7 +56,6 @@ 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
@@ -65,7 +64,6 @@ 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
@@ -83,7 +81,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.lens import com.kyant.backdrop.effects.refractionWithDispersion
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
@@ -205,11 +203,10 @@ class MomentumAnimation(
} }
} }
@SuppressLint("UnrememberedMutableState")
@Composable @Composable
fun StyledSlider( fun StyledSlider(
label: String? = null, label: String? = null,
value: Float, mutableFloatState: MutableFloatState,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(), backdrop: Backdrop = rememberLayerBackdrop(),
@@ -220,26 +217,23 @@ 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 derivedStateOf { val fraction by remember {
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) derivedStateOf {
.fastCoerceIn(0f, 1f) ((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f)
}
} }
val sliderBackdrop = rememberLayerBackdrop() val sliderBackdrop = rememberLayerBackdrop()
@@ -248,8 +242,6 @@ 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)
@@ -435,93 +427,71 @@ fun StyledSlider(
) )
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
} }
.then( .draggable(
if (enabled) { rememberDraggableState { delta ->
Modifier val trackWidth = trackWidthState.floatValue
.draggable( if (trackWidth > 0f) {
rememberDraggableState { delta -> val targetFraction = fraction + delta / trackWidth
val trackWidth = trackWidthState.floatValue val targetValue =
if (trackWidth > 0f) { lerp(valueRange.start, valueRange.endInclusive, targetFraction)
val targetFraction = fraction + delta / trackWidth .fastCoerceIn(valueRange.start, valueRange.endInclusive)
val targetValue = val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
lerp( targetValue,
valueRange.start, snapPoints,
valueRange.endInclusive, snapThreshold
targetFraction ) else targetValue
) onValueChange(snappedValue)
.fastCoerceIn( }
valueRange.start, },
valueRange.endInclusive Orientation.Horizontal,
) startDragImmediately = true,
snapPoints.forEach { snap -> onDragStarted = {
if ((lastDragValue < snap && targetValue >= snap) || // Remove this block as momentumAnimation handles pressing
(snap in targetValue..<lastDragValue)) { },
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) onDragStopped = {
} // Remove this block as momentumAnimation handles pressing
} onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
lastDragValue = targetValue }
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( )
targetValue, .then(momentumAnimation.modifier)
snapPoints, .drawBackdrop(
snapThreshold, rememberCombinedBackdrop(backdrop, sliderBackdrop),
) else targetValue { RoundedCornerShape(28.dp) },
onValueChange(snappedValue) highlight = {
} val progress = momentumAnimation.progress
}, Highlight.Ambient.copy(alpha = progress)
Orientation.Horizontal, },
startDragImmediately = true, shadow = {
onDragStarted = { Shadow(
lastDragValue = value radius = 4f.dp,
}, color = Color.Black.copy(0.05f)
onDragStopped = { )
onValueChange((value * 100).roundToInt() / 100f) },
} innerShadow = {
) val progress = momentumAnimation.progress
.then(momentumAnimation.modifier) InnerShadow(
.drawBackdrop( radius = 4f.dp * progress,
rememberCombinedBackdrop(backdrop, sliderBackdrop), alpha = progress
{ RoundedCornerShape(28.dp) }, )
highlight = { },
val progress = momentumAnimation.progress layerBlock = {
Highlight.Ambient.copy(alpha = progress) scaleX = momentumAnimation.scaleX
}, scaleY = momentumAnimation.scaleY
shadow = { val velocity = momentumAnimation.velocity / 5000f
Shadow( scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
radius = 4f.dp, scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
color = Color.Black.copy(0.05f) },
) onDrawSurface = {
}, val progress = momentumAnimation.progress
innerShadow = { drawRect(Color.White.copy(alpha = 1f - progress))
val progress = momentumAnimation.progress },
InnerShadow( effects = {
radius = 4f.dp * progress, val progress = momentumAnimation.progress
alpha = progress blur(8f.dp.toPx() * (1f - progress))
) refractionWithDispersion(
}, height = 6f.dp.toPx() * progress,
layerBlock = { amount = size.height / 2f * progress
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)
@@ -596,13 +566,12 @@ fun StyledSliderPreview() {
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
Column ( Box (
Modifier.align(Alignment.Center), Modifier.align(Alignment.Center)
verticalArrangement = Arrangement.spacedBy(16.dp)
) )
{ {
StyledSlider( StyledSlider(
value = a.floatValue, mutableFloatState = a,
onValueChange = { onValueChange = {
a.floatValue = it a.floatValue = it
}, },
@@ -613,19 +582,6 @@ 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
)
} }
} }
} }
@@ -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.presentation.components package me.kavishdevar.librepods.composables
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.Animatable
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.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,10 +58,8 @@ 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
@@ -70,12 +68,11 @@ 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.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.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable @Composable
fun StyledSwitch( fun StyledSwitch(
@@ -84,12 +81,9 @@ 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( val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
0x805B5B5E
) else Color(0xFFD1D1D6)
val trackWidth = 64.dp val trackWidth = 64.dp
val trackHeight = 28.dp val trackHeight = 28.dp
@@ -104,25 +98,24 @@ 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 scope = rememberCoroutineScope() val animationScope = 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 targetColor = if (checked) onColor else offColor val animatedTrackColor = remember { Animatable(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)
@@ -147,7 +140,7 @@ fun StyledSwitch(
modifier = Modifier modifier = Modifier
.layerBackdrop(switchBackdrop) .layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2)) .clip(RoundedCornerShape(trackHeight / 2))
.background(animatedTrackColor) .background(animatedTrackColor.value)
.width(trackWidth) .width(trackWidth)
.height(trackHeight) .height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -161,31 +154,27 @@ 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)
scope.launch { animationScope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += abs(delta) totalDrag.floatValue += kotlin.math.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
scope.launch { animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
scope.launch { animationScope.launch {
if (totalDrag.floatValue < tapThreshold) { if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked val newChecked = !checked
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -273,12 +262,7 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress)) drawRect(Color.White.copy(1f - progress))
}, },
effects = { effects = {
lens( refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
} }
) )
.width(thumbWidth) .width(thumbWidth)
@@ -0,0 +1,687 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
independent: Boolean = true,
enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by checkedState
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
attHandle: ATTHandles,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = try {
attManager.read(attHandle).getOrNull(0)?.toInt()
} catch (e: Exception) {
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
null
} ?: 0
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
attManager.enableNotifications(attHandle)
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
LaunchedEffect(checked) {
if (attManager.socket?.isConnected != true) return@LaunchedEffect
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
}
val listener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
if (value.isNotEmpty()) {
checked = value[0].toInt() != 0
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
} else {
Log.w("StyledToggle", "Empty value in notification for $label")
}
}
}
}
LaunchedEffect(Unit) {
attManager.registerListener(attHandle, listener)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(attHandle, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val context = LocalContext.current
val sharedPrefs = context.getSharedPreferences("preview", 0)
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
sharedPreferences = sharedPrefs
)
}
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -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.data package me.kavishdevar.librepods.constants
import android.os.Parcelable import android.os.Parcelable
import android.util.Log import android.util.Log
@@ -40,7 +40,6 @@ 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
@@ -59,7 +58,6 @@ 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
} }
} }
@@ -80,8 +78,6 @@ 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 {
@@ -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.data package me.kavishdevar.librepods.constants
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction { enum class StemAction {
PLAY_PAUSE, PLAY_PAUSE,
@@ -1,70 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
return aacpManager.controlCommandStatusList.associate {
it.identifier to it.value
}
}
}
@@ -1,8 +0,0 @@
package me.kavishdevar.librepods.data
interface XposedRemotePref {
fun isAvailable(): Boolean
fun getBoolean(key: String, def: Boolean): Boolean
fun putBoolean(key: String, value: Boolean)
}
@@ -1,5 +0,0 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}
@@ -1,214 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.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
)
}
}
@@ -1,177 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(
batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val 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
)
}
}
@@ -1,118 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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
)
}
}
@@ -1,273 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.components
import androidx.compose.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,130 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.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,
tint = 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
)
}
}
}
@@ -1,539 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.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 flipped =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
CallControlSettings(
hazeState = hazeState,
flipped = flipped == true,
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,
tint = 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
)
)
}
}
}
}
}
@@ -1,698 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.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.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.verticalScroll
import androidx.compose.material3.AlertDialog
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
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.NavigationButton
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.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import java.util.Locale.getDefault
@Composable
fun AppSettingsScreen(
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
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) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = 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
),
)
}
}
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 (BuildConfig.FLAVOR == "xposed") {
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
)
if (BuildConfig.FLAVOR == "xposed") {
Spacer(modifier = Modifier.height(16.dp))
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) + "\n" + stringResource(
R.string.requires_xposed
).replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() },
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) {
NavigationButton(
to = "troubleshooting",
name = stringResource(R.string.troubleshooting),
navController = navController,
independent = true,
description = stringResource(R.string.troubleshooting_description)
)
}
Spacer(modifier = Modifier.height(8.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))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "",
name = stringResource(R.string.email),
navController = navController,
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ")
putExtra(
Intent.EXTRA_TEXT,
"\n\n\n----------" +
"\nPhone details:" +
"\nDEVICE: ${Build.DEVICE}" +
"\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
},
independent = false
)
HorizontalDivider(
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(8.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))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_NAME, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version_code), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.flavor), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.FLAVOR, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_type), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.BUILD_TYPE,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
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
)
}
})
}
}
}
}
@@ -1,111 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.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)
}
}
}
@@ -1,496 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.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 = "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 = "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.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,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.buy),
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,
) {
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))
}
}
}
@@ -1,537 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.viewmodel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.util.Log
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.BuildConfig
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 var billingFirstCollectDone = false
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) {
Log.d("AirPodsViewModel", "we are not premium")
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
} else {
Log.d("AirPodsViewModel", "we are premium")
}
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (!isDemoMode) when (intent?.action) {
AirPodsNotifications.AIRPODS_CONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = true)
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = false)
}
}
AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
?.toList() ?: emptyList()
_uiState.update {
it.copy(battery = data)
}
}
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 as AACPManager.ControlCommandListener
}
// 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)
Log.d("AirPodsViewModel", "Hello???? $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) {
service.attManager?.write(handle, value)
}
}
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()
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(10000)
}
}
}
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 purchase(context: Context) {
// BillingManager.provider.purchase(context as Activity)
// }
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"
)
}
}
}
@@ -1,169 +0,0 @@
package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application
import android.content.Context
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.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
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
)
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()
init {
loadSettings()
observeBilling()
}
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)
)
}
if (BuildConfig.FLAVOR == "xposed") {
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) }
}
}
@@ -1,47 +0,0 @@
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.queryPurchases()
}
}
@@ -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.presentation.screens package me.kavishdevar.librepods.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
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
@@ -39,25 +39,23 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -71,41 +69,80 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.bluetooth.AACPManager import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.presentation.components.StyledDropdown import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
//private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
//private var toneVolumeDebounceJob: Job? = null private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings" private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) { fun AccessibilitySettingsScreen(navController: NavController) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1 val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
) }
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.accessibility) title = stringResource(R.string.accessibility)
) { topPadding, hazeState, bottomPadding -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -115,256 +152,273 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (!state.isPremium) { val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
StyledButton( val phoneEQEnabled = remember { mutableStateOf(false) }
onClick = { val mediaEQEnabled = remember { mutableStateOf(false) }
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue =
val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0) aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
?.get(0)
var selectedPressSpeed by remember { var selectedPressSpeed by remember {
mutableStateOf( mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
) )
} }
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
}
val pressAndHoldDurationOptions = mapOf( val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue =
val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0) aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
?.get(0)
var selectedPressAndHoldDuration by remember { var selectedPressAndHoldDuration by remember {
mutableStateOf( mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0] ?: pressAndHoldDurationOptions[0]
) )
} }
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressAndHoldDuration =
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
}
val volumeSwipeSpeedOptions = mapOf( val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option), 1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer), 2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest) 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0) val selectedVolumeSwipeSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
?.get(0)
var selectedVolumeSwipeSpeed by remember { var selectedVolumeSwipeSpeed by remember {
mutableStateOf( mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1] ?: volumeSwipeSpeedOptions[1]
) )
} }
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
// phoneMediaDebounceJob?.cancel() if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
// delay(150) selectedVolumeSwipeSpeed =
// val manager = ServiceManager.getService()?.aacpManager volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
// if (manager == null) { }
// Log.w(TAG, "Cannot write EQ: AACPManager not available") }
// return@launch }
// } LaunchedEffect(Unit) {
// try { aacpManager?.registerControlCommandListener(
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() selectedVolumeSwipeSpeedListener
// Log.d(
// TAG,
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
// )
// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
// } catch (e: Exception) {
// Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
// }
// }
// }
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
DisposableEffect(Unit) {
Box ( onDispose {
modifier = Modifier.then( aacpManager?.unregisterControlCommandListener(
if (!state.isPremium) { AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
Modifier selectedVolumeSwipeSpeedListener
.pointerInput(Unit) { )
awaitPointerEventScope { }
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
} }
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch
}
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(
TAG,
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
)
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
}
}
}
val toneVolumeValue = remember { mutableFloatStateOf(
aacpManager?.controlCommandStatusList?.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
) }
LaunchedEffect(toneVolumeValue.floatValue) {
toneVolumeDebounceJob?.cancel()
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
return@launch
}
try {
manager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
)
} catch (e: Exception) {
Log.w(TAG, "Error sending tone volume: ${e.message}")
}
}
}
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
StyledToggle( StyledToggle(
title = stringResource(R.string.noise_control), title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) },
enabled = state.isPremium
) )
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) { if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
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),
checked = state.loudSoundReductionEnabled, attHandle = ATTHandles.LOUD_SOUND_REDUCTION
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) },
enabled = state.isPremium
) )
} }
if (!hearingAidEnabled && state.vendorIdHook) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton( NavigationButton(
to = "transparency_customization", to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), name = stringResource(R.string.customize_transparency_mode),
navController = navController, navController = navController
enabled = state.isPremium
) )
} }
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f
StyledSlider( StyledSlider(
label = stringResource(R.string.tone_volume), label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description), description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue, mutableFloatState = toneVolumeValue,
onValueChange = { onValueChange = {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50)) toneVolumeValue.floatValue = it
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1", startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9", endIcon = "\uDBC0\uDEA9",
independent = true, independent = true
enabled = state.isPremium
) )
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) },
enabled = state.isPremium
) )
Box ( DropdownMenuComponent(
modifier = Modifier.then( label = stringResource(R.string.volume_swipe_speed),
if (!state.isPremium) { description = stringResource(R.string.volume_swipe_speed_description),
Modifier options = volumeSwipeSpeedOptions.values.toList(),
.pointerInput(Unit) { selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
awaitPointerEventScope { onOptionSelected = { newValue ->
while (true) { selectedVolumeSwipeSpeed = newValue
val event = awaitPointerEvent(PointerEventPass.Initial) aacpManager?.sendControlCommand(
event.changes.forEach { it.consume() } identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
} value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
} ?: 1.toByte()
} )
} else Modifier },
) textColor = textColor,
) { hazeState = hazeState,
DropdownMenuComponent( independent = true
label = stringResource(R.string.volume_swipe_speed), )
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
} }
// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to),
// style = TextStyle( // style = TextStyle(
@@ -586,7 +640,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
// } // }
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) }
} }
} }
} }
@@ -612,9 +666,6 @@ private fun DropdownMenuComponent(
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()){ Column(modifier = Modifier.fillMaxWidth()){
Column( Column(
@@ -674,11 +725,7 @@ private fun DropdownMenuComponent(
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
@@ -689,9 +736,6 @@ private fun DropdownMenuComponent(
lastDismissTime = System.currentTimeMillis() lastDismissTime = System.currentTimeMillis()
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null parentHoveredIndex = null
}, },
onDragCancel = { onDragCancel = {
@@ -0,0 +1,135 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.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)
)
}
}
}
@@ -0,0 +1,453 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.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)
}
}
}
@@ -0,0 +1,981 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.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
)
}
}
)
}
}
}
}
@@ -0,0 +1,146 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.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)
}
}
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.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.presentation.components.StyledIconButton import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.data.isHeadTrackingData import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -335,11 +335,12 @@ fun DebugScreen(navController: NavController) {
expandedItems.value = emptySet() expandedItems.value = emptySet()
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { topPadding, hazeState, bottomPadding -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -348,7 +349,7 @@ fun DebugScreen(navController: NavController) {
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
@@ -21,13 +21,12 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.screens
import android.graphics.Paint import android.content.Context
import android.graphics.RadialGradient import android.os.Build
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
@@ -46,7 +45,6 @@ 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
@@ -76,6 +74,7 @@ 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
@@ -84,9 +83,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
@@ -95,13 +94,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton 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.StyledToggle import me.kavishdevar.librepods.composables.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
@@ -109,14 +107,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(viewModel: AirPodsViewModel, navController: NavController) { fun HeadTrackingScreen() {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
viewModel.startHeadTracking() ServiceManager.getService()?.startHeadTracking()
onDispose { onDispose {
viewModel.stopHeadTracking() ServiceManager.getService()?.stopHeadTracking()
} }
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -129,22 +127,25 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
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 (!state.headTrackingActive) { if (ServiceManager.getService()?.isHeadTrackingActive == false) {
viewModel.startHeadTracking() ServiceManager.getService()?.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started") Log.d("HeadTrackingScreen", "Head tracking started")
} else { } else {
viewModel.stopHeadTracking() ServiceManager.getService()?.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped") Log.d("HeadTrackingScreen", "Head tracking stopped")
} }
}, },
icon = if (state.headTrackingActive) "􀊅" else "􀊃", icon = if (isActive) "􀊅" else "􀊃",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { topPadding, hazeState, _ -> ) { spacerHeight, 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()
@@ -165,49 +166,35 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = 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",
checked = state.headGesturesEnabled, sharedPreferences = sharedPreferences,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, sharedPreferenceKey = "head_gestures",
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 = 14.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontFamily = FontFamily(Font(R.font.sf_pro)) color = textColor
), ),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
) )
HeadVisualization() HeadVisualization()
@@ -215,12 +202,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
Text( Text(
"Velocity", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontFamily = FontFamily(Font(R.font.sf_pro)) color = textColor
), ),
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
) )
AccelerationPlot() AccelerationPlot()
@@ -396,8 +383,7 @@ 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(),
@@ -472,9 +458,9 @@ private fun HeadVisualization() {
spherePath.close() spherePath.close()
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = android.graphics.Paint().apply {
style = Paint.Style.FILL style = android.graphics.Paint.Style.FILL
shader = RadialGradient( shader = android.graphics.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,
@@ -486,14 +472,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),
Shader.TileMode.CLAMP android.graphics.Shader.TileMode.CLAMP
) )
} }
drawPath(spherePath.asAndroidPath(), paint) drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = Paint().apply { val highlightPaint = android.graphics.Paint().apply {
style = Paint.Style.FILL style = android.graphics.Paint.Style.FILL
shader = RadialGradient( shader = android.graphics.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,
@@ -503,15 +489,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 0.3f, 1f), floatArrayOf(0f, 0.3f, 1f),
Shader.TileMode.CLAMP android.graphics.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 = Paint().apply { val secondaryHighlightPaint = android.graphics.Paint().apply {
style = Paint.Style.FILL style = android.graphics.Paint.Style.FILL
shader = RadialGradient( shader = android.graphics.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,
@@ -520,15 +506,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 1f), floatArrayOf(0f, 1f),
Shader.TileMode.CLAMP android.graphics.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 = Paint().apply { val shadowPaint = android.graphics.Paint().apply {
style = Paint.Style.FILL style = android.graphics.Paint.Style.FILL
shader = RadialGradient( shader = android.graphics.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,
@@ -537,7 +523,7 @@ private fun HeadVisualization() {
android.graphics.Color.BLACK android.graphics.Color.BLACK
), ),
floatArrayOf(0.7f, 1f), floatArrayOf(0.7f, 1f),
Shader.TileMode.CLAMP android.graphics.Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 40 else 20 alpha = if (darkTheme) 40 else 20
} }
@@ -597,13 +583,13 @@ private fun HeadVisualization() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = android.graphics.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 = Paint.Align.RIGHT textAlign = android.graphics.Paint.Align.RIGHT
typeface = Typeface.create( typeface = android.graphics.Typeface.create(
"SF Pro", "SF Pro",
Typeface.NORMAL android.graphics.Typeface.NORMAL
) )
} }
@@ -648,8 +634,7 @@ 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
@@ -717,10 +702,10 @@ private fun AccelerationPlot() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = android.graphics.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 = Paint.Align.RIGHT textAlign = android.graphics.Paint.Align.RIGHT
} }
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
@@ -733,20 +718,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 = Paint().apply { val paint = android.graphics.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 = Paint.Align.LEFT textAlign = android.graphics.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 = Paint().apply { val paint = android.graphics.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 = Paint.Align.LEFT textAlign = android.graphics.Paint.Align.LEFT
} }
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
} }
@@ -754,3 +739,11 @@ private fun AccelerationPlot() {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable
fun HeadTrackingScreenPreview() {
HeadTrackingScreen()
}
@@ -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.presentation.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -31,10 +31,9 @@ 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
@@ -42,6 +41,7 @@ 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,16 +50,15 @@ 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.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.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings import me.kavishdevar.librepods.utils.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
@@ -70,14 +69,13 @@ private const val TAG = "HearingAidAdjustments"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
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 state by viewModel.uiState.collectAsState() val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments) title = stringResource(R.string.adjustments)
@@ -127,6 +125,25 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
) )
} }
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) {
@@ -148,6 +165,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
} }
} }
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")
@@ -226,7 +256,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
value = amplificationSliderValue.floatValue, mutableFloatState = amplificationSliderValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -238,15 +268,14 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
StyledToggle( StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
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,
value = balanceSliderValue.floatValue, mutableFloatState = balanceSliderValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -259,7 +288,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
value = toneSliderValue.floatValue, mutableFloatState = toneSliderValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -271,7 +300,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue, mutableFloatState = ambientNoiseReductionSliderValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -282,8 +311,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value, checkedState = conversationBoostEnabled,
onCheckedChange = { conversationBoostEnabled.value = it },
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description)
) )
@@ -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.presentation.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -37,9 +37,8 @@ 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
@@ -62,14 +61,15 @@ 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.bluetooth.AACPManager import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.data.sendTransparencySettings import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
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,22 +78,23 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HearingAidScreen(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 = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID] val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG] val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold 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
@@ -101,7 +102,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_aid), title = stringResource(R.string.hearing_aid),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { topPadding, hazeState, bottomPadding -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -112,18 +113,43 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
// val mediaAssistEnabled = remember { mutableStateOf(false) } // val 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) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02)) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte()) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
} }
initialLoad.value = false initialLoad.value = false
@@ -160,8 +186,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.hearing_aid), label = stringResource(R.string.hearing_aid),
checked = hearingAidEnabled.value, checkedState = hearingAidEnabled,
onCheckedChange = { hearingAidEnabled.value = it },
independent = false independent = false
) )
HorizontalDivider( HorizontalDivider(
@@ -173,7 +198,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, 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
) )
} }
@@ -192,7 +217,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, 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
) )
@@ -233,9 +258,9 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, 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",
@@ -244,32 +269,25 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
dismissText = "Cancel", dismissText = "Cancel",
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte() val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
if (!enrolled) { if (!enrolled) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
} else { } else {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01)) aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
} }
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte()) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
hearingAidEnabled.value = true hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
if (state.hearingAidData.isEmpty()) { val data = attManager.read(ATTHandles.TRANSPARENCY)
Log.w(TAG, "read failed") val parsed = parseTransparencySettingsResponse(data)
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) sendTransparencySettings(attManager, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}") Log.e(TAG, "Error disabling transparency: ${e.message}")
} }
} }
}, },
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
hazeState = hazeStateS.value, hazeState = hazeStateS.value,
// backdrop = backdrop // backdrop = backdrop
) )
@@ -16,33 +16,50 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.presentation.screens 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.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.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.graphics.Color
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 me.kavishdevar.librepods.BuildConfig import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel 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 @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel) { 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() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_protection), title = stringResource(R.string.hearing_protection),
) { spacerHeight -> ) { spacerHeight ->
@@ -54,37 +71,20 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
if (state.vendorIdHook) { StyledToggle(
StyledToggle( title = stringResource(R.string.environmental_noise),
title = stringResource(R.string.environmental_noise), label = stringResource(R.string.loud_sound_reduction),
label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description),
description = stringResource(R.string.loud_sound_reduction_description), attHandle = ATTHandles.LOUD_SOUND_REDUCTION
checked = state.loudSoundReductionEnabled, )
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle( StyledToggle(
title = stringResource(R.string.workspace_use), title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe), label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description), description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull( controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
) )
} }
} }
} }
@@ -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.presentation.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -28,9 +28,12 @@ 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
@@ -39,9 +42,17 @@ 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.presentation.components.StyledScaffold 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 import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -65,7 +76,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 = LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val libraries by produceLibraries { val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries) context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader() .bufferedReader()
@@ -79,4 +90,4 @@ fun OpenSourceLicensesScreen(navController: NavController) {
) )
} }
} }
} }
@@ -18,29 +18,41 @@
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.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
@@ -54,34 +66,54 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.SelectItem import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledButton 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.StyledSelectList import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.utils.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(viewModel: AirPodsViewModel, name: String, navController: NavController) { fun LongPress(navController: NavController, name: 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 state by viewModel.uiState.collectAsState() val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 }?.value?.takeIf { it.isNotEmpty() }?.get(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"
@@ -92,8 +124,9 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
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)
@@ -115,36 +148,11 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
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,
tint = 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(
@@ -153,7 +161,6 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
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
@@ -169,11 +176,10 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
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 = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS] val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
?.get(0)?.toInt() it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
?: sharedPreferences.getInt("long_press_byte", 0b0101) }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: 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) {
@@ -191,8 +197,8 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
} else { } else {
currentByte or bit currentByte or bit
} }
viewModel.setControlCommandByte( ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -217,8 +223,8 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
} else { } else {
currentByte or bit currentByte or bit
} }
viewModel.setControlCommandByte( ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -240,8 +246,8 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
} else { } else {
currentByte or bit currentByte or bit
} }
viewModel.setControlCommandByte( ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -263,8 +269,8 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
} else { } else {
currentByte or bit currentByte or bit
} }
viewModel.setControlCommandByte( ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -290,7 +296,9 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
} }
} }
} }
Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}") Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
} }
fun countEnabledModes(byteValue: Int): Int { fun countEnabledModes(byteValue: Int): Int {
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.screens
import android.content.Context import android.content.Context
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -53,22 +53,26 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue 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.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.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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun RenameScreen(viewModel: AirPodsViewModel) { fun RenameScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) } val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -111,7 +115,7 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
onValueChange = { onValueChange = {
name.value = it name.value = it
sharedPreferences.edit {putString("name", it.text)} sharedPreferences.edit {putString("name", it.text)}
viewModel.setName(it.text) ServiceManager.getService()?.setName(it.text)
}, },
textStyle = TextStyle( textStyle = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
@@ -155,3 +159,9 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
} }
} }
} }
@Preview
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}
@@ -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.presentation.screens package me.kavishdevar.librepods.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
@@ -42,9 +41,8 @@ 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
@@ -60,20 +58,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.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.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.data.TransparencySettings import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.data.sendTransparencySettings import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel // import me.kavishdevar.librepods.utils.RadareOffsetFinder
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
@@ -83,10 +84,14 @@ private const val TAG = "TransparencySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { fun TransparencySettingsScreen(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 attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
// 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)
@@ -94,12 +99,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
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)
){ topPadding, hazeState, bottomPadding -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -109,7 +111,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
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) }
@@ -146,6 +148,23 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
) )
} }
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,
@@ -186,22 +205,35 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
balance = balanceSliderValue.floatValue balance = balanceSliderValue.floatValue
) )
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) sendTransparencySettings(attManager, 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 {
Log.d(TAG, "Found AACPManager, reading cached EQ data") if (aacpManager != null) {
val aacpEQ = state.eqData Log.d(TAG, "Found AACPManager, reading cached EQ data")
if (aacpEQ.isNotEmpty()) { val aacpEQ = aacpManager.eqData
eq.value = aacpEQ.copyOf() if (aacpEQ.isNotEmpty()) {
phoneMediaEQ.value = aacpEQ.copyOf() eq.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") phoneMediaEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else { } else {
Log.d(TAG, "AACPManager EQ data empty") Log.d(TAG, "No AACPManager available")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
@@ -211,7 +243,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
try { try {
val data = state.transparencyData val data = attManager.read(ATTHandles.TRANSPARENCY)
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) {
@@ -244,19 +276,19 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
} }
} }
if (state.vendorIdHook) { // Only show transparency mode section if SDP offset is available
if (isSdpOffsetAvailable.value) {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checked = enabled.value, checkedState = enabled,
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,
value = amplificationSliderValue.floatValue, mutableFloatState = amplificationSliderValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -268,7 +300,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
value = balanceSliderValue.floatValue, mutableFloatState = balanceSliderValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -281,7 +313,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
value = toneSliderValue.floatValue, mutableFloatState = toneSliderValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -293,7 +325,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue, mutableFloatState = ambientNoiseReductionSliderValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -304,12 +336,14 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checked = conversationBoostEnabled.value, checkedState = conversationBoostEnabled,
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(
@@ -409,8 +443,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -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.presentation.screens package me.kavishdevar.librepods.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.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.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)
){ topPadding, hazeState, bottomPadding -> ){ spacerHeight, hazeState ->
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(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
Text( Text(
text = stringResource(R.string.saved_logs), text = stringResource(R.string.saved_logs),
@@ -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.presentation.screens package me.kavishdevar.librepods.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
@@ -32,6 +33,7 @@ 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
@@ -53,25 +55,31 @@ 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.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings import me.kavishdevar.librepods.utils.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() { fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager val attManager = ServiceManager.getService()?.attManager
if (attManager == null) { if (attManager == null) {
@@ -88,7 +96,7 @@ fun UpdateHearingTestScreen() {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_test) title = stringResource(R.string.hearing_test)
) { topPadding, hazeState, bottomPadding -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -100,7 +108,7 @@ fun UpdateHearingTestScreen() {
) { ) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(spacerHeight))
Text( Text(
text = stringResource(R.string.hearing_test_value_instruction), text = stringResource(R.string.hearing_test_value_instruction),
@@ -130,17 +138,17 @@ fun UpdateHearingTestScreen() {
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue, leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.floatValue, rightAmplification = rightAmplification.value,
leftTone = tone.floatValue, leftTone = tone.value,
rightTone = tone.floatValue, rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue, leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue, rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2, netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.value
) )
) )
} }
@@ -153,11 +161,11 @@ fun UpdateHearingTestScreen() {
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.floatValue = parsed.leftTone tone.value = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification ownVoiceAmplification.value = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification leftAmplification.value = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification rightAmplification.value = 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")
@@ -173,45 +181,31 @@ fun UpdateHearingTestScreen() {
} }
} }
LaunchedEffect( LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
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( Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
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.floatValue, leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.floatValue, rightAmplification = rightAmplification.value,
leftTone = tone.floatValue, leftTone = tone.value,
rightTone = tone.floatValue, rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue, leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue, rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2, netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.value
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
@@ -246,17 +240,14 @@ fun UpdateHearingTestScreen() {
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.floatValue = parsedSettings.leftTone tone.value = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification leftAmplification.value = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification rightAmplification.value = parsedSettings.rightAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
Log.d( Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
} }
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -265,8 +256,7 @@ fun UpdateHearingTestScreen() {
} }
} }
val frequencies = val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -352,7 +342,6 @@ fun UpdateHearingTestScreen() {
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -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.presentation.screens package me.kavishdevar.librepods.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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
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,23 +45,36 @@ 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.presentation.components.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun VersionScreen(viewModel: AirPodsViewModel) { fun VersionScreen(navController: NavController) {
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.version) title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -80,8 +93,7 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
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 +120,7 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
) )
) )
Text( Text(
text = state.version1, text = airpodsInstance.version1 ?: "N/A",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -137,7 +149,7 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
) )
) )
Text( Text(
text = state.version2, text = airpodsInstance.version2 ?: "N/A",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -166,7 +178,7 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
) )
) )
Text( Text(
text = state.version3, text = airpodsInstance.version3 ?: "N/A",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -177,4 +189,4 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
} }
} }
} }
} }
@@ -35,9 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@@ -98,7 +98,7 @@ class AirPodsQSService : TileService() {
Log.d("AirPodsQSService", "onStartListening") Log.d("AirPodsQSService", "onStartListening")
val service = ServiceManager.getService() val service = ServiceManager.getService()
isAirPodsConnected = service?.isConnected() == true isAirPodsConnected = service?.isConnectedLocally == true
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
@@ -151,7 +151,7 @@ class AirPodsQSService : TileService() {
return return
} }
val clickBehavior = "cycle" // sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog" val clickBehavior = sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog"
if (clickBehavior == "dialog") { if (clickBehavior == "dialog") {
launchDialogActivity() launchDialogActivity()
@@ -244,10 +244,8 @@ class AirPodsQSService : TileService() {
private fun getNextAncMode(): Int { private fun getNextAncMode(): Int {
val availableModes = getAvailableModes() val availableModes = getAvailableModes()
Log.d("AirPodsQSService", "availableModes: $availableModes, currentAncMode: $currentAncMode")
val currentIndex = availableModes.indexOf(currentAncMode) val currentIndex = availableModes.indexOf(currentAncMode)
val nextIndex = (currentIndex + 1) % availableModes.size val nextIndex = (currentIndex + 1) % availableModes.size
Log.d("AirPodsQSService", "nextIndex: $nextIndex")
return availableModes[nextIndex] return availableModes[nextIndex]
} }
File diff suppressed because it is too large Load Diff
@@ -40,13 +40,13 @@ val cameraPackages = mutableSetOf(
var cameraOpen = false var cameraOpen = false
private var currentCustomPackage: String? = null private var currentCustomPackage: String? = null
class AppListenerService: AccessibilityService() { class AppListenerService : AccessibilityService() {
private lateinit var prefs: android.content.SharedPreferences private lateinit var prefs: android.content.SharedPreferences
private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
if (key == "custom_camera_package") { if (key == "custom_camera_package") {
val newPackage = sharedPreferences.getString(key, null) val newPackage = sharedPreferences.getString(key, null)
currentCustomPackage?.let { cameraPackages.remove(it) } currentCustomPackage?.let { cameraPackages.remove(it) }
if (!newPackage.isNullOrBlank()) { if (newPackage != null && newPackage.isNotBlank()) {
cameraPackages.add(newPackage) cameraPackages.add(newPackage)
} }
currentCustomPackage = newPackage currentCustomPackage = newPackage
@@ -57,7 +57,7 @@ class AppListenerService: AccessibilityService() {
super.onCreate() super.onCreate()
prefs = getSharedPreferences("settings", MODE_PRIVATE) prefs = getSharedPreferences("settings", MODE_PRIVATE)
val customPackage = prefs.getString("custom_camera_package", null) val customPackage = prefs.getString("custom_camera_package", null)
if (!customPackage.isNullOrBlank()) { if (customPackage != null && customPackage.isNotBlank()) {
cameraPackages.add(customPackage) cameraPackages.add(customPackage)
currentCustomPackage = customPackage currentCustomPackage = customPackage
} }
@@ -95,4 +95,4 @@ class AppListenerService: AccessibilityService() {
} }
override fun onInterrupt() {} override fun onInterrupt() {}
} }
@@ -17,7 +17,7 @@
*/ */
package me.kavishdevar.librepods.presentation.theme package me.kavishdevar.librepods.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -27,4 +27,4 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
@@ -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.presentation.theme package me.kavishdevar.librepods.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -61,4 +61,4 @@ fun LibrePodsTheme(
typography = Typography, typography = Typography,
content = content content = content
) )
} }
@@ -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.presentation.theme package me.kavishdevar.librepods.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -49,4 +49,4 @@ val Typography = Typography(
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
*/ */
) )
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.bluetooth package me.kavishdevar.librepods.utils
import android.util.Log import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -43,7 +43,7 @@ class AACPManager {
const val EAR_DETECTION: Byte = 0x06 const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4B const val CONVERSATION_AWARENESS: Byte = 0x4B
const val INFORMATION: Byte = 0x1D const val INFORMATION: Byte = 0x1D
const val RENAME: Byte = 0x1A const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17 const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val PROXIMITY_KEYS_RSP: Byte = 0x31
@@ -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 value: ByteArray val identifier: ControlCommandIdentifiers,
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,31 +84,42 @@ class AACPManager {
// @Suppress("unused") // @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) { enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE( MIC_MODE(0x01),
0x15 BUTTON_SEND_MODE(0x05),
), VOICE_TRIGGER(0x12),
CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS( SINGLE_CLICK_MODE(0x14),
0x1A DOUBLE_CLICK_MODE(0x15),
), CLICK_HOLD_MODE(0x16),
ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE( DOUBLE_CLICK_INTERVAL(0x17),
0x1E CLICK_HOLD_INTERVAL(0x18),
), LISTENING_MODE_CONFIGS(0x1A),
CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE( ONE_BUD_ANC_MODE(0x1B),
0x25 CROWN_ROTATION_DIRECTION(0x1C),
), LISTENING_MODE(0x0D),
ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG( AUTO_ANSWER_MODE(0x1E),
0x28 CHIME_VOLUME(0x1F),
), VOLUME_SWIPE_INTERVAL(0x23),
SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE( CALL_MANAGEMENT_CONFIG(0x24),
0x30 VOLUME_SWIPE_MODE(0x25),
), ADAPTIVE_VOLUME_CONFIG(0x26),
IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION( SOFTWARE_MUTE_CONFIG(0x27),
0x34 CONVERSATION_DETECT_CONFIG(0x28),
), SSL(0x29),
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 ¯\_(ツ)_/¯ HEARING_AID(0x2C),
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG( AUTO_ANC_STRENGTH(0x2E),
0x37 HPS_GAIN_SWIPE(0x2F),
), 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 {
@@ -118,44 +129,59 @@ class AACPManager {
} }
enum class ProximityKeyType(val value: Byte) { enum class ProximityKeyType(val value: Byte) {
IRK(0x01), ENC_KEY(0x04); IRK(0x01),
ENC_KEY(0x04);
companion object { companion object {
fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte } fun fromByte(byte: Byte): ProximityKeyType =
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") ProximityKeyType.entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
} }
} }
enum class StemPressType(val value: Byte) { enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08); SINGLE_PRESS(0x05),
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object { companion object {
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte } fun fromByte(byte: Byte): StemPressType? =
entries.find { it.value == byte }
} }
} }
enum class StemPressBudType(val value: Byte) { enum class StemPressBudType(val value: Byte) {
LEFT(0x01), RIGHT(0x02); LEFT(0x01),
RIGHT(0x02);
companion object { companion object {
fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte } fun fromByte(byte: Byte): StemPressBudType? =
entries.find { it.value == byte }
} }
} }
enum class AudioSourceType(val value: Byte) { enum class AudioSourceType(val value: Byte) {
NONE(0x00), CALL(0x01), MEDIA(0x02); NONE(0x00),
CALL(0x01),
MEDIA(0x02);
companion object { companion object {
fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte } fun fromByte(byte: Byte): AudioSourceType? =
entries.find { it.value == byte }
} }
} }
data class AudioSource( data class AudioSource(
val mac: String, val type: AudioSourceType val mac: String,
val type: AudioSourceType
) )
data class ConnectedDevice( data class ConnectedDevice(
val mac: String, val info1: Byte, val info2: Byte, var type: String? val mac: String,
val info1: Byte,
val info2: Byte,
var type: String?
) )
data class AirPodsInformation( data class AirPodsInformation(
@@ -190,7 +216,7 @@ class AACPManager {
var audioSource: AudioSource? = null var audioSource: AudioSource? = null
private set private set
var eqData = FloatArray(8) var eqData = FloatArray(8) { 0.0f }
private set private set
var eqOnPhone: Boolean = false var eqOnPhone: Boolean = false
@@ -204,7 +230,8 @@ class AACPManager {
} }
private fun setControlCommandStatusValue( private fun setControlCommandStatusValue(
identifier: ControlCommandIdentifiers, value: ByteArray identifier: ControlCommandIdentifiers,
value: ByteArray
) { ) {
val existingStatus = getControlCommandStatus(identifier) val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) { if (existingStatus == value) {
@@ -238,7 +265,6 @@ 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> {
@@ -261,13 +287,15 @@ class AACPManager {
} }
fun registerControlCommandListener( fun registerControlCommandListener(
identifier: ControlCommandIdentifiers, callback: ControlCommandListener identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
) { ) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
} }
fun unregisterControlCommandListener( fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers, callback: ControlCommandListener identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
) { ) {
controlCommandListeners[identifier]?.remove(callback) controlCommandListeners[identifier]?.remove(callback)
} }
@@ -302,7 +330,8 @@ 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, value ControlCommandIdentifiers.fromByte(identifier) ?: return false,
value
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
@@ -311,14 +340,16 @@ 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, byteArrayOf(value) ControlCommandIdentifiers.fromByte(identifier) ?: return false,
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, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) identifier,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
) )
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false,
@@ -338,7 +369,8 @@ class AACPManager {
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> { fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d( Log.d(
TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}" TAG,
"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")
@@ -366,9 +398,11 @@ class AACPManager {
keys[ProximityKeyType.fromByte(keyType)] = key keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ TAG,
key.joinToString(" ") { "%02X".format(it) } "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
}") key.joinToString(" ") { "%02X".format(it) }
}"
)
} }
return keys return keys
} }
@@ -388,21 +422,26 @@ 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, "Received packet does not start with expected header: ${ TAG,
packet.joinToString(" ") { "Received packet does not start with expected header: ${
"%02X".format(it) packet.joinToString(" ") {
} "%02X".format(it)
}") }
}"
)
return return
} }
if (packet.size < 6) { if (packet.size < 6) {
Log.w( Log.w(
TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" TAG,
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
) )
return return
} }
when (val opcode = packet[4]) { val opcode = packet[4]
when (opcode) {
Opcodes.BATTERY_INFO -> { Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet) callback?.onBatteryInfoReceived(packet)
} }
@@ -417,9 +456,10 @@ 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) }
}") }"
)
val controlCommandListText = try { Log.d(
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(
@@ -427,19 +467,12 @@ 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 {
@@ -467,11 +500,13 @@ class AACPManager {
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
TAG, "Received HEADTRACKING packet too short: ${ TAG,
packet.joinToString(" ") { "Received HEADTRACKING packet too short: ${
"%02X".format(it) packet.joinToString(" ") {
} "%02X".format(it)
}") }
}"
)
return return
} }
callback?.onHeadTrackingReceived(packet) callback?.onHeadTrackingReceived(packet)
@@ -503,8 +538,7 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> { Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString() val packetString = packet.decodeToString()
val sender = val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
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
@@ -524,15 +558,9 @@ 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( Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
TAG,
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
)
if (packetString.contains("SetOwnershipToFalse")) { if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest( callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
sender,
packetString.contains("ReverseBannerTapped")
)
} }
if (packetString.contains("ShowNearbyUI")) { if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI(sender) callback?.onShowNearbyUI(sender)
@@ -557,33 +585,25 @@ 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 visible. 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 enabled. just directly the EQ... weird.
// the EQs are little endian floats // the EQs are little endian floats
val eq1 = val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq4 = ByteBuffer.wrap(packet, 108, 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.d(TAG, "Parsing Information Packet") Log.e(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet) val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
else -> { else -> {
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -613,22 +633,10 @@ class AACPManager {
fun createHandshakePacket(): ByteArray { fun createHandshakePacket(): ByteArray {
return byteArrayOf( return byteArrayOf(
0x00, 0x00, 0x00, 0x04, 0x00,
0x00, 0x01, 0x00, 0x02, 0x00,
0x04, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00
0x01,
0x00,
0x02,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
) )
} }
@@ -765,40 +773,25 @@ class AACPManager {
val packet = ByteArray(5 + size) val packet = ByteArray(5 + size)
packet[0] = Opcodes.RENAME packet[0] = Opcodes.RENAME
packet[1] = 0x00 packet[1] = 0x00
packet[2] = 0x01 packet[2] = size.toByte()
packet[3] = size.toByte() packet[3] = 0x00
packet[4] = 0x00 System.arraycopy(nameBytes, 0, packet, 4, size)
System.arraycopy(nameBytes, 0, packet, 5, size)
return packet return packet
} }
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( 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}"))) {
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( Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
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( return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
createMediaInformationNewDevicePacket(
selfMacAddress,
targetMacAddress
)
)
} }
fun createMediaInformationNewDevicePacket( fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
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(
@@ -887,13 +880,17 @@ 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, targetMac, streamingState selfMacAddress,
targetMac,
streamingState
) )
) )
} }
fun createMediaInformationPacket( fun createMediaInformationPacket(
selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true selfMacAddress: String,
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)
@@ -926,7 +923,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 {
@@ -1008,15 +1005,9 @@ 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( 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}"))) {
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( Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
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")
@@ -1050,7 +1041,8 @@ class AACPManager {
} }
data class ControlCommand( data class ControlCommand(
val identifier: Byte, val value: ByteArray val identifier: Byte,
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
@@ -1102,8 +1094,10 @@ class AACPManager {
triplePressCustomized: Boolean = false, triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false longPressCustomized: Boolean = false
): Boolean { ): Boolean {
val value = val value = ((if (singlePressCustomized) 0x01 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 (doublePressCustomized) 0x02 else 0) or
(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
@@ -1118,18 +1112,19 @@ 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, "Control command: ${controlCommand.identifier.toHexString()} - ${ TAG,
controlCommand.value.joinToString(" ") { "%02X".format(it) } "Control command: ${controlCommand.identifier.toHexString()} - ${
}") 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() ?: return false val socket = BluetoothConnectionManager.getCurrentSocket()
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
@@ -1206,10 +1201,7 @@ 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( Log.w(TAG, "Data array too short to parse all connected devices, returning what we have")
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)
@@ -1223,7 +1215,6 @@ 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(
@@ -21,7 +21,7 @@
* 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.bluetooth package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -31,8 +31,8 @@ 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
@@ -63,22 +63,18 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
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: Job? = null private var notificationJob: kotlinx.coroutines.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(adapter, device, uuid) socket = createBluetoothSocket(adapter, device, uuid)
try { socket!!.connect()
socket!!.connect()
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
return
}
input = socket!!.inputStream input = socket!!.inputStream
output = socket!!.outputStream output = socket!!.outputStream
Log.d(TAG, "Connected to ATT") Log.d(TAG, "Connected to ATT")
@@ -225,12 +221,7 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
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
@@ -16,8 +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.data package me.kavishdevar.librepods.utils
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(
@@ -50,48 +52,33 @@ 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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_1_buds,
// budsRes = R.drawable.airpods_1_buds leftBudsRes = R.drawable.airpods_1_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_1_right,
// leftBudsRes = R.drawable.airpods_1_left caseRes = R.drawable.airpods_1_case,
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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_2_buds,
// budsRes = R.drawable.airpods_2_buds leftBudsRes = R.drawable.airpods_2_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_2_right,
// leftBudsRes = R.drawable.airpods_2_left caseRes = R.drawable.airpods_2_case,
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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_3_buds,
// budsRes = R.drawable.airpods_3_buds leftBudsRes = R.drawable.airpods_3_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_3_right,
// leftBudsRes = R.drawable.airpods_3_left caseRes = R.drawable.airpods_3_case,
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
) )
@@ -100,16 +87,11 @@ 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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_4_buds,
// budsRes = R.drawable.airpods_4_buds leftBudsRes = R.drawable.airpods_4_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_4_right,
// leftBudsRes = R.drawable.airpods_4_left caseRes = R.drawable.airpods_4_case,
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,
@@ -120,24 +102,18 @@ 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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_4_buds,
// budsRes = R.drawable.airpods_4_buds leftBudsRes = R.drawable.airpods_4_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_4_right,
// leftBudsRes = R.drawable.airpods_4_left caseRes = R.drawable.airpods_4_case,
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
) )
) )
@@ -145,16 +121,11 @@ 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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_pro_1_buds,
// budsRes = R.drawable.airpods_pro_1_buds leftBudsRes = R.drawable.airpods_pro_1_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_pro_1_right,
// leftBudsRes = R.drawable.airpods_pro_1_left caseRes = R.drawable.airpods_pro_1_case,
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
) )
@@ -164,15 +135,10 @@ 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,
@@ -192,15 +158,10 @@ 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,
@@ -220,16 +181,11 @@ 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,
budCaseRes = R.drawable.airpods_pro_2, budsRes = R.drawable.airpods_pro_3_buds,
// budsRes = R.drawable.airpods_pro_3_buds leftBudsRes = R.drawable.airpods_pro_3_left,
budsRes = R.drawable.airpods_pro_2_buds, rightBudsRes = R.drawable.airpods_pro_3_right,
// leftBudsRes = R.drawable.airpods_pro_3_left caseRes = R.drawable.airpods_pro_3_case,
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,
@@ -256,6 +212,8 @@ 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 {
@@ -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.bluetooth package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
@@ -30,10 +30,8 @@ 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
@@ -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.bluetooth package me.kavishdevar.librepods.utils
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
@@ -0,0 +1,289 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.ParcelUuid
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.services.ServiceManager
import java.io.IOException
import java.util.UUID
import kotlin.io.encoding.ExperimentalEncodingApi
enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
}
object CrossDevice {
var initialized = false
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
private var serverSocket: BluetoothServerSocket? = null
private var clientSocket: BluetoothSocket? = null
private lateinit var bluetoothAdapter: BluetoothAdapter
private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
private const val MANUFACTURER_ID = 0x1234
private const val MANUFACTURER_DATA = "ALN_AirPods"
var isAvailable: Boolean = false // set to true when airpods are connected to another device
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences
private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false)
var disconnectionRequested = false
@SuppressLint("MissingPermission")
fun init(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
Log.d("CrossDevice", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
// startAdvertising()
startServer()
initialized = true
}
}
@SuppressLint("MissingPermission")
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
if (!bluetoothAdapter.isEnabled) return@launch
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
if (!bluetoothAdapter.isEnabled) {
serverSocket?.close()
break
}
if (clientSocket != null) {
try {
clientSocket!!.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
try {
val socket = serverSocket!!.accept()
handleClientConnection(socket)
} catch (e: IOException) { }
}
}
}
@SuppressLint("MissingPermission", "unused")
private fun startAdvertising() {
CoroutineScope(Dispatchers.IO).launch {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
try {
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
}
Log.d("CrossDevice", "BLE Advertising started")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d("CrossDevice", "BLE Advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
}
}
fun setAirPodsConnected(connected: Boolean) {
if (connected) {
isAvailable = false
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} else {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
// Reset state variables
isAvailable = true
}
}
fun sendReceivedPacket(packet: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream != null) {
return
}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
}
private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex"
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry)
sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)}
}
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("CrossDevice", "Client connected")
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
var bytes: Int
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
while (true) {
try {
bytes = inputStream.read(buffer)
} catch (e: IOException) {
e.printStackTrace()
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
val s = serverSocket?.accept()
if (s != null) {
handleClientConnection(s)
}
break
}
var packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnectForCD()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
disconnectionRequested = false
}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
isAvailable = true
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
isAvailable = false
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
} else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) }
if (packet.size % 2 == 0) {
val half = packet.size / 2
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
Log.d("CrossDevice", "Duplicated packet, trimming")
packet = packet.sliceArray(0 until half)
}
}
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
// ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBattery()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
val newEarDetectionStatus = listOf(
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.librepods.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
}
}
}
}
}
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream == null) {
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
Log.d("CrossDevice", "Sent packet to remote device")
}
fun notifyAirPodsConnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
fun notifyAirPodsDisconnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
}
@@ -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.data package me.kavishdevar.librepods.utils
import android.util.Log import android.util.Log
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -25,8 +25,6 @@ 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
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.overlays package me.kavishdevar.librepods.utils
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.data.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.data.BatteryComponent import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.constants.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,7 +240,6 @@ 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(
@@ -380,11 +379,7 @@ class IslandWindow(private val context: Context) {
videoView.start() videoView.start()
} }
try { windowManager.addView(containerView, params)
windowManager.addView(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
islandView.post { islandView.post {
initialHeight = islandView.height initialHeight = islandView.height

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